diff --git a/.coveragerc b/.coveragerc index b2e8e1510..20f4ed8b2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -13,6 +13,7 @@ exclude_lines = # Don't complain about importerror handlers except ImportError + except ModuleNotFoundError # Don't complain if tests don't hit defensive assertion code: raise AssertionError diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..5f760dd00 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,82 @@ +# Cache files +**/*.egg-info +**/*.pyc +.coverage +.mypy_cache +.pytest_cache +.ropeproject +**/.sass-cache +**/.webassets-cache +**/__pycache__ +.webpack_cache +.cache + +# MacOS file +**/.DS_Store + +# Assets and static files +funnel/static/build +funnel/static/service-worker.js +funnel/static/service-worker.js.map +funnel/static/img/fa5-packed.svg +**/node_modules +**/*.packed.css +**/*.packed.js +**/baseframe-packed.* + +# Download files +geoname_data/ +download/ + +# Dependencies +build/dependencies + +# Log and test files +error.log +error.log.* +ghostdriver.log +geckodriver.log +tests/cypress/screenshots +tests/cypress/videos +tests/screenshots +tests/unit/utils/markdown/data/output.html +coverage/ + +# Instance files that should not be checked-in +instance/development.py +instance/hasgeekapp.py +instance/production.py +instance/settings.py +instance/container_env + +# Forbidden secrets +secrets.dev +secrets.test +newrelic.ini +.env +.env.* + +# Local venvs (Idea creates them) +venv/ +env/ +.python-version + +# Editors +.idea/ +.vscode/ +.project +.pydevproject +.settings +*.wpr +.vscode +*.sublime-workspace + +# Versioning files +.git + +# Local DBs +dump.rdb +test.db +*.bz2 +*.gz +*.sql diff --git a/.env.testing-sample b/.env.testing-sample deleted file mode 100644 index c1a92291b..000000000 --- a/.env.testing-sample +++ /dev/null @@ -1,57 +0,0 @@ -# Run Flask in testing environment -FLASK_ENV='testing' - -# Specify HOSTALIASES file for platforms that support it -HOSTALIASES=${PWD}/HOSTALIASES - -# Mail settings -MAIL_SERVER='localhost' -MAIL_PORT=25 -MAIL_DEFAULT_SENDER='test@example.com' -SITE_SUPPORT_EMAIL='' -ADMINS='' - -# Keys for tests specifically -FACEBOOK_OAUTH_TOKEN='' - -# Twitter integration -OAUTH_TWITTER_KEY='' -OAUTH_TWITTER_SECRET='' - -# GitHub integration -OAUTH_GITHUB_KEY='' -OAUTH_GITHUB_SECRET='' - -# Google integration -OAUTH_GOOGLE_KEY='' -OAUTH_GOOGLE_SECRET='' - -# Recaptcha for the registration form -RECAPTCHA_PUBLIC_KEY='' -RECAPTCHA_PRIVATE_KEY='' - -# Boxoffice settings for sync tests in Cypress -CYPRESS_BOXOFFICE_SECRET_KEY='' -CYPRESS_BOXOFFICE_ACCESS_KEY'' -CYPRESS_BOXOFFICE_IC_ID= -CYPRESS_BOXOFFICE_CLIENT_ID='' - -# Google Maps API key -GOOGLE_MAPS_API_KEY='' - -# YouTube API key -YOUTUBE_API_KEY='' - -# Twilio SID -SMS_TWILIO_SID='' -# Twilio Token -SMS_TWILIO_TOKEN='' -# Twilio test number -SMS_TWILIO_FROM='+15005550006' - -# Vimeo client id -VIMEO_CLIENT_ID='' -# Vimeo client secret -VIMEO_CLIENT_SECRET='' -# Vimeo access token -VIMEO_ACCESS_TOKEN='' diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 000000000..ce2163fdb --- /dev/null +++ b/.flaskenv @@ -0,0 +1,6 @@ +# The settings in this file are secondary to .env, which overrides + +# Assume production by default, unset debug and testing state +FLASK_DEBUG=false +FLASK_DEBUG_TB_ENABLED=false +FLASK_TESTING=false diff --git a/.gitattributes b/.gitattributes index 54f008073..dc177a25e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,42 @@ -* text=auto +* text=auto eol=lf -*.py text eol=lf +*.gif binary +*.ico binary +*.jpg binary +*.mo binary +*.png binary +*.webp binary + +.coveragerc text eol=lf +.dockerignore text eol=lf +.flaskenv text eol=lf +.gitattributes text eol=lf +.gitignore text eol=lf +Dockerfile text eol=lf +HOSTALIASES text eol=lf +Makefile text eol=lf +*.cfg text eol=lf +*.css text eol=lf +*.Dockerfile text eol=lf +*.env text eol=lf +*.feature text eol=lf +*.html text eol=lf +*.in text eol=lf +*.ini text eol=lf +*.jinja2 text eol=lf *.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.po text eol=lf +*.pot text eol=lf +*.py text eol=lf +*.rb text eol=lf +*.rst text eol=lf +*.sample text eol=lf *.scss text eol=lf -*.jinja2 text eol=lf -*.toml text eol=lf \ No newline at end of file +*.sh text eol=lf +*.svg text eol=lf +*.toml text eol=lf +*.txt text eol=lf +*.yaml text eol=lf +*.yml text eol=lf diff --git a/.github/workflows/docker-ci-tests.yml b/.github/workflows/docker-ci-tests.yml new file mode 100644 index 000000000..bf055aeeb --- /dev/null +++ b/.github/workflows/docker-ci-tests.yml @@ -0,0 +1,71 @@ +name: 'Pytest on docker' + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + paths: + - '**.py' + - '**.js' + - '**.scss' + - '**.jinja2' + - 'requirements/base.txt' + - 'requirements/test.txt' + - '.github/workflows/docker-ci-tests.yml' + - 'Dockerfile' + - 'pyproject.toml' + - '.eslintrc.js' + - 'docker-compose.yml' + - 'docker/compose/services.yml' + - 'docker/entrypoints/ci-test.sh' + - 'docker/initdb/test.sh' + - 'package.json' +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Cache npm + uses: actions/cache@v3 + with: + path: .cache/.npm + key: docker-npm + - name: Cache node_modules + uses: actions/cache@v3 + with: + path: node_modules + key: docker-node_modules-${{ hashFiles('package-lock.json') }} + - name: Cache pip + uses: actions/cache@v3 + with: + path: .cache/pip + key: docker-pip + - name: Cache .local + uses: actions/cache@v3 + with: + path: .cache/.local + key: docker-user-local + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Build funnel-test image + id: build-funnel-test + uses: docker/build-push-action@v4 + with: + context: . + file: ci.Dockerfile + tags: funnel-test:latest + load: true + push: false + - name: Run Tests + run: make docker-ci-test + - name: Upload coverage report to Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.github_token }} + path-to-lcov: coverage/funnel.lcov + flag-name: docker-3.11 diff --git a/.github/workflows/mypy-ratchet.yml b/.github/workflows/mypy-ratchet.yml new file mode 100644 index 000000000..0a8d46e0b --- /dev/null +++ b/.github/workflows/mypy-ratchet.yml @@ -0,0 +1,30 @@ +name: Mypy Ratchet + +on: + push: + paths: + - '**.py' + - 'requirements/*.txt' + - '.github/workflows/mypy-ratchet.yml' + +jobs: + mypy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: 'requirements/*.txt' + + - name: Install Python dependencies + run: | + make install-python-dev + + - name: Run mypy + run: | + mypy . --strict --no-warn-unused-ignores | mypy-json-report parse --diff-old-report mypy-ratchet.json --output-file mypy-ratchet.json || true diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a6cbfd70e..225d8256e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -6,35 +6,35 @@ name: Pytest on: push: - branches: ['main'] - pull_request: - branches: ['main'] paths: - '**.py' - '**.js' + - '**.scss' - '**.jinja2' - workflow_call: - inputs: - requirements: - description: Updated requirements.txt - type: string - requirements_dev: - description: Updated requirements_dev.txt - type: string - requirements_test: - description: Updated requirements_test.txt - type: string + - '.flaskenv' + - '.testenv' + - 'package-lock.json' + - 'requirements/base.txt' + - 'requirements/test.txt' + - '.github/workflows/pytest.yml' permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} strategy: fail-fast: false matrix: - python-version: ['3.7', '3.10'] + os: [ubuntu-latest] # TODO: Figure out macos-latest and Docker + python-version: ['3.11', '3.12'] services: redis: @@ -56,6 +56,9 @@ jobs: - 5432:5432 steps: + - name: Setup Docker on macOS + uses: docker-practice/actions-setup-docker@1.0.11 + if: ${{ matrix.os == 'macos-latest' }} - name: Checkout code uses: actions/checkout@v3 - name: Install Firefox (for browser testing) @@ -63,54 +66,64 @@ jobs: - name: Install Geckodriver (for browser testing) uses: browser-actions/setup-geckodriver@latest - name: Install Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: pip + cache-dependency-path: 'requirements/*.txt' + - name: Cache python packages + uses: actions/cache@v3 + with: + path: ${{ env.pythonLocation }} + key: ${{ matrix.os }}-${{ env.pythonLocation }}-${{ hashFiles('requirements/base.txt') }}-${{ hashFiles('requirements.txt/test.txt') }} + - name: Install Python dependencies + run: make install-python-test - name: Install Node uses: actions/setup-node@v3 with: - node-version: latest + node-version: 20 cache: npm + - name: Cache node modules + uses: actions/cache@v3 + with: + path: node_modules + key: ${{ join(matrix.*, '-') }}-node_modules-${{ hashFiles('package-lock.json') }} + - name: Cache built assets + uses: actions/cache@v3 + with: + path: funnel/static/build + key: ${{ join(matrix.*, '-') }}-assets-build + - name: Cache .webpack_cache + uses: actions/cache@v3 + with: + path: .webpack_cache + key: ${{ join(matrix.*, '-') }}-webpack_cache + - name: Install Node dependencies + run: make install-npm + - name: Build Webpack assets + run: make assets + - name: Annotate Pytest failures in PR + run: pip install pytest-github-actions-annotate-failures - name: Setup hostnames run: | sudo -- sh -c "echo '127.0.0.1 funnel.test' >> /etc/hosts" sudo -- sh -c "echo '127.0.0.1 f.test' >> /etc/hosts" - - name: Optionally replace requirements.txt - if: ${{ inputs.requirements }} - run: | - echo ${{ inputs.requirements }} > requirements.txt - - name: Optionally replace requirements_dev.txt - if: ${{ inputs.requirements_dev }} - run: | - echo ${{ inputs.requirements_dev }} > requirements_dev.txt - - name: Optionally replace requirements_test.txt - if: ${{ inputs.requirements_test }} - run: | - echo ${{ inputs.requirements_test }} > requirements_test.txt - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements_test.txt - - name: Install pytest-github-actions-annotate-failures - run: pip install pytest-github-actions-annotate-failures - - name: Install Node modules - run: npm ci - - name: Webpack JS and CSS assets - run: npm run build - name: Create PostgreSQL databases run: | sudo apt-get install postgresql-client -y psql -h localhost -U postgres -c "create user $(whoami);" psql -h localhost -U postgres -c "create database funnel_testing;" psql -h localhost -U postgres -c "create database geoname_testing;" + set -a; source .testenv; set +a FLASK_ENV=testing flask dbconfig | psql -h localhost -U postgres funnel_testing FLASK_ENV=testing flask dbconfig | psql -h localhost -U postgres geoname_testing psql -h localhost -U postgres -c "grant all privileges on database funnel_testing to $(whoami);" psql -h localhost -U postgres -c "grant all privileges on database geoname_testing to $(whoami);" + psql -h localhost -U postgres funnel_testing -c "grant all privileges on schema public to $(whoami); grant all privileges on all tables in schema public to $(whoami); grant all privileges on all sequences in schema public to $(whoami);" + psql -h localhost -U postgres geoname_testing -c "grant all privileges on schema public to $(whoami); grant all privileges on all tables in schema public to $(whoami); grant all privileges on all sequences in schema public to $(whoami);" - name: Test with pytest run: | - pytest -vv --showlocals --splinter-headless --cov=funnel + pytest --disable-warnings --gherkin-terminal-reporter -vv --showlocals --cov=funnel - name: Prepare coverage report run: | mkdir -p coverage diff --git a/.github/workflows/telegram.yml b/.github/workflows/telegram.yml index 085bc3c93..feb7b1e99 100644 --- a/.github/workflows/telegram.yml +++ b/.github/workflows/telegram.yml @@ -47,6 +47,7 @@ jobs: "StephanieBr": {"tguser": "@stephaniebrne"}, "vidya-ram": {"tguser": "@vidya_ramki"}, "zainabbawa": {"tguser": "@Saaweoh"}, + "anishTP": {"tguser": "@anishtp"}, ".*": {"tguser": "Unknown"} } export_to: env @@ -139,4 +140,4 @@ jobs: format: html disable_web_page_preview: true message: | - ${{ github.event_name }} by ${{ needs.tguser.outputs.tguser }} (${{ github.actor }}) in ${{ github.repository }}: ${{ github.event.head_commit.message }} ${{ github.event.compare }} + ${{ github.event_name }} by ${{ needs.tguser.outputs.tguser }} (${{ github.actor }}) in ${{ github.repository }}/${{ github.ref_name }}: ${{ github.event.head_commit.message }} ${{ github.event.compare }} diff --git a/.gitignore b/.gitignore index 38cb34ba4..ca291c340 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,18 @@ *.egg-info *.pyc .coverage +.eslintcache .mypy_cache .pytest_cache .ropeproject .sass-cache -.sass-cache .webassets-cache +.webpack_cache __pycache__ +.nox +monkeytype.sqlite3 +.ci-cache +.cache # MacOS file .DS_Store @@ -26,7 +31,11 @@ node_modules baseframe-packed.* # Download files -geoname_data +geoname_data/ +download/ + +# Dependencies +build/dependencies # Log and test files error.log @@ -51,14 +60,14 @@ secrets.dev secrets.test newrelic.ini .env -.env.testing -.env.staging -.env.development -.env.production +.env.* # Local venvs (Idea creates them) venv/ env/ +.python-version +.venv +.envrc # Editors .idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e34507633..8d0ab4a4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,43 +2,61 @@ # See https://pre-commit.com/hooks.html for more hooks default_stages: [commit] # Enable this to enforce a common Python version: -# default_language_version: -# python: python3.9 +default_language_version: + python: python3.11 ci: - skip: ['pip-compile', 'yesqa', 'no-commit-to-branch'] + skip: [ + 'pip-audit', + 'yesqa', + 'no-commit-to-branch', + # 'hadolint-docker', + 'docker-compose-check', + ] repos: - repo: https://github.com/pre-commit-ci/pre-commit-ci-config - rev: v1.5.1 + rev: v1.6.1 hooks: - id: check-pre-commit-ci-config - - repo: https://github.com/jazzband/pip-tools - rev: 6.12.1 - hooks: - - id: pip-compile - name: pip-compile requirements.in - args: ['--output-file', 'requirements.txt', 'requirements.in'] - files: ^requirements\.(in|txt)$ - - id: pip-compile - name: pip-compile requirements_test.in - args: - [ - '--output-file', - 'requirements_test.txt', - 'requirements.in', - 'requirements_test.in', - ] - files: ^requirements(_test)?\.(in|txt)$ - - id: pip-compile - name: pip-compile requirements_dev.in - args: - [ - '--output-file', - 'requirements_dev.txt', - 'requirements.in', - 'requirements_test.in', - 'requirements_dev.in', + - repo: https://github.com/peterdemin/pip-compile-multi + rev: v2.6.3 + hooks: + - id: pip-compile-multi-verify + files: ^requirements/.*\.(in|txt)$ + - repo: https://github.com/pypa/pip-audit + rev: v2.6.1 + hooks: + - id: pip-audit + args: [ + '--disable-pip', + '--no-deps', + '--skip-editable', + '-r', + 'requirements/base.txt', + '-r', + 'requirements/test.txt', + '-r', + 'requirements/dev.txt', + '--ignore-vuln', + 'PYSEC-2021-13', # https://github.com/pallets-eco/flask-caching/pull/209 + '--ignore-vuln', + 'PYSEC-2022-42969', # https://github.com/pytest-dev/pytest/issues/10392 + '--ignore-vuln', + 'PYSEC-2023-73', # https://github.com/RedisLabs/redisraft/issues/608 + '--ignore-vuln', + 'PYSEC-2023-101', # https://github.com/pytest-dev/pytest-selenium/issues/310 ] - files: ^requirements(_test|_dev)?\.(in|txt)$ + files: ^requirements/.*\.txt$ + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: ['--keep-runtime-typing', '--py310-plus'] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.0 + hooks: + - id: ruff + args: ['--fix', '--exit-non-zero-on-fix'] + # Extra args, only after removing flake8 and yesqa: '--extend-select', 'RUF100' - repo: https://github.com/lucasmbrown/mirrors-autoflake rev: v1.3 hooks: @@ -51,85 +69,85 @@ repos: '--remove-unused-variables', '--remove-duplicate-keys', ] - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 - hooks: - - id: pyupgrade - args: - ['--keep-runtime-typing', '--py3-plus', '--py36-plus', '--py37-plus'] - repo: https://github.com/asottile/yesqa - rev: v1.4.0 + rev: v1.5.0 hooks: - id: yesqa additional_dependencies: &flake8deps - - bandit==1.7.4 - - flake8-assertive==2.1.0 - - flake8-blind-except==0.2.1 - - flake8-bugbear==22.10.27 - - flake8-builtins==2.0.1 - - flake8-comprehensions==3.10.1 - - flake8-docstrings==1.6.0 - - flake8-isort==5.0.3 - - flake8-logging-format==0.9.0 - - flake8-mutable==1.2.0 - - flake8-plugin-utils==1.3.2 - - flake8-print==5.0.0 - - flake8-pytest-style==1.6.0 - - pep8-naming==0.13.2 - - toml==0.10.2 - - tomli==2.0.1 + - bandit + # - flake8-annotations + - flake8-assertive + - flake8-blind-except + - flake8-bugbear + - flake8-builtins + - flake8-comprehensions + - flake8-docstrings + - flake8-isort + - flake8-logging-format + - flake8-mutable + - flake8-plugin-utils + - flake8-print + - flake8-pytest-style + - pep8-naming + - toml + - tomli - repo: https://github.com/PyCQA/isort - rev: 5.11.4 + rev: 5.12.0 hooks: - id: isort additional_dependencies: - - toml + - tomli - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.10.0 hooks: - id: black - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.991 - hooks: - - id: mypy - # warn-unused-ignores is unsafe with pre-commit, see - # https://github.com/python/mypy/issues/2960 - args: ['--no-warn-unused-ignores', '--ignore-missing-imports'] - additional_dependencies: - - lxml-stubs - - sqlalchemy-stubs - - toml - - types-all - - typing-extensions + # Mypy is temporarily disabled until the SQLAlchemy 2.0 migration is complete + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.3.0 + # hooks: + # - id: mypy + # # warn-unused-ignores is unsafe with pre-commit, see + # # https://github.com/python/mypy/issues/2960 + # args: ['--no-warn-unused-ignores', '--ignore-missing-imports'] + # additional_dependencies: + # - flask + # - lxml-stubs + # - sqlalchemy + # - toml + # - tomli + # - types-chevron + # - types-geoip2 + # - types-python-dateutil + # - types-pytz + # - types-requests + # - typing-extensions - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: *flake8deps - repo: https://github.com/PyCQA/pylint - rev: v2.16.0b1 + rev: v3.0.1 hooks: - id: pylint args: [ '--disable=import-error', '-rn', # Disable full report '-sn', # Disable evaluation score - '--ignore-paths=tests,migrations', + '--ignore-paths=migrations', ] additional_dependencies: - - pylint-flask-sqlalchemy - - pylint-pytest - - toml + - tomli - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 + rev: 1.7.5 hooks: - id: bandit language_version: python3 args: ['-c', 'pyproject.toml'] additional_dependencies: - - toml + - 'bandit[toml]' - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-added-large-files - id: check-ast @@ -155,26 +173,42 @@ repos: args: ['--remove'] - id: forbid-new-submodules - id: mixed-line-ending + - id: name-tests-test + args: ['--pytest'] - id: no-commit-to-branch - id: requirements-txt-fixer - files: requirements(_.*)?\.in + files: requirements/.*\.in - id: trailing-whitespace args: ['--markdown-linebreak-ext=md'] + - repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.5.4 + hooks: + - id: forbid-crlf + - id: remove-crlf + - id: forbid-tabs + - id: remove-tabs - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0-alpha.4 + rev: v3.0.3 hooks: - id: prettier - args: ['--single-quote', '--trailing-comma', 'es5'] + args: + ['--single-quote', '--trailing-comma', 'es5', '--end-of-line', 'lf'] exclude: funnel/templates/js/ - - repo: https://github.com/Riverside-Healthcare/djLint - rev: v1.19.13 - hooks: - - id: djlint-jinja - files: \.html\.(jinja2|j2)$ - - id: djlint-handlebars - files: \.html\.(mustache|hb)$ - repo: https://github.com/ducminh-phan/reformat-gherkin rev: v3.0.1 hooks: - id: reformat-gherkin files: \.feature$ + # - repo: https://github.com/Lucas-C/pre-commit-hooks-nodejs + # rev: v1.1.2 + # hooks: + # - id: dockerfile_lint + # files: .*Dockerfile.* + # - repo: https://github.com/hadolint/hadolint + # rev: v2.12.1-beta + # hooks: + # - id: hadolint-docker + - repo: https://github.com/IamTheFij/docker-pre-commit + rev: v3.0.1 + hooks: + - id: docker-compose-check diff --git a/.prettierrc.js b/.prettierrc.js index a425d3f76..c90450753 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,4 +1,5 @@ module.exports = { + endOfLine: 'lf', singleQuote: true, trailingComma: 'es5', }; diff --git a/.safety-policy.yml b/.safety-policy.yml new file mode 100644 index 000000000..84a93c620 --- /dev/null +++ b/.safety-policy.yml @@ -0,0 +1,6 @@ +security: + ignore-vulnerabilities: + 33026: + reason: https://github.com/pallets-eco/flask-caching/pull/209 + 42969: + reason: https://github.com/pytest-dev/pytest/issues/10392 diff --git a/.testenv b/.testenv new file mode 100644 index 000000000..f60567fe7 --- /dev/null +++ b/.testenv @@ -0,0 +1,54 @@ +# This file is public. To override, make a new file named `.env.testing` and set +# override values there. Values will be processed as JSON, falling back to plain strings + +FLASK_ENV=testing +FLASK_TESTING=true +FLASK_DEBUG_TB_ENABLED=false +# Enable CSRF so tests reflect production use +FLASK_WTF_CSRF_ENABLED=true +# Use Redis cache so that rate limit validation tests work, with Redis db +FLASK_CACHE_TYPE=flask_caching.backends.RedisCache +REDIS_HOST=localhost +FLASK_RQ_REDIS_URL=redis://${REDIS_HOST}:6379/9 +FLASK_RQ_DASHBOARD_REDIS_URL=redis://${REDIS_HOST}:6379/9 +FLASK_CACHE_REDIS_URL=redis://${REDIS_HOST}:6379/9 +# Disable logging in tests +FLASK_SQLALCHEMY_ECHO=false +FLASK_LOG_FILE=null +FLASK_LOG_EMAIL_TO='[]' +FLASK_LOG_TELEGRAM_CHATID=null +FLASK_LOG_TELEGRAM_APIKEY=null +FLASK_LOG_SLACK_WEBHOOKS='[]' +# Run RQ jobs inline in tests +FLASK_RQ_ASYNC=false +# Recaptcha keys from https://developers.google.com/recaptcha/docfaq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do +FLASK_RECAPTCHA_USE_SSL=true +FLASK_RECAPTCHA_PUBLIC_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI +FLASK_RECAPTCHA_PRIVATE_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe +FLASK_RECAPTCHA_OPTIONS="" +# Use hostaliases on supported platforms +HOSTALIASES=${PWD}/HOSTALIASES +# These settings should be customisable from a .env file (TODO) +FLASK_SECRET_KEYS='["testkey"]' +FLASK_LASTUSER_SECRET_KEYS='["testkey"]' +FLASK_LASTUSER_COOKIE_DOMAIN='.funnel.test:3002' +FLASK_SITE_TITLE='Test Hasgeek' +FLASK_SITE_SUPPORT_EMAIL='support@hasgeek.com' +FLASK_SITE_SUPPORT_PHONE='+917676332020' +FLASK_MAIL_DEFAULT_SENDER="Funnel " +DB_HOST=localhost +FLASK_SQLALCHEMY_DATABASE_URI=postgresql+psycopg://${DB_HOST}/funnel_testing +FLASK_SQLALCHEMY_BINDS__geoname=postgresql+psycopg://${DB_HOST}/geoname_testing +FLASK_TIMEZONE='Asia/Kolkata' +FLASK_BOXOFFICE_SERVER='http://boxoffice:6500/api/1/' +FLASK_IMGEE_HOST='http://imgee.test:4500' +FLASK_IMAGE_URL_DOMAINS='["images.example.com"]' +FLASK_IMAGE_URL_SCHEMES='["https"]' +FLASK_SES_NOTIFICATION_TOPICS=null +# Per app config +APP_FUNNEL_SITE_ID=hasgeek-test +APP_FUNNEL_SERVER_NAME=funnel.test:3002 +APP_FUNNEL_SHORTLINK_DOMAIN=f.test:3002 +APP_FUNNEL_DEFAULT_DOMAIN=funnel.test +APP_FUNNEL_UNSUBSCRIBE_DOMAIN=bye.test +APP_SHORTLINK_SITE_ID=shortlink-test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e382e1e2f..000000000 --- a/.travis.yml +++ /dev/null @@ -1,51 +0,0 @@ -language: python -python: - - 3.9 -addons: - postgresql: 13 - apt: - packages: - - postgresql-13 - - postgresql-client-13 - - postgresql-13-hll - -# The "secure" values are secrets which encode these variables -# SMS_TWILIO_SID (Test Account ID for Twilio for tests to pass) -# SMS_TWILIO_TOKEN (Test Account Password for Twilio for tests to pass) -# SMS_TWILIO_FROM (Test From Number for Twilio for tests to pass) -env: - global: - - PGVER=13 - - PGPORT=5433 - - secure: VSk63d0fSpVr5HNKORE9QJ01BoRkE4PyiADMnO6n7ka0TULzeIyCoPmwNlwaSPi3UounssdLUsR9SOPUwg8FLPBiYoHoTqxaL2y6dVJcP7F1uW8ofJ3M3+edOHfjY/txkktQ36os0pXXFukSzVDajA4J/vZ2A9Pj8nnqmF5siJc= - - secure: bi2i66oahTdm00psMe6FuTRVmTubcqZms1nm2UUrllLhALRfJDcT7boBsIkM/pSEHCI76yVVHCQxAL9ouEu0kBlCV9aCCPh0MAAGSVn+LE7ru0U76C9Yoivok5wDJpXo+zUo+RPYdn/VGlY6XI1nAZgur3ZjnkkgUp8dKhcNoHw= - - secure: ZmRtFNNRZkk1kOkPCV5jmMuXnestL8tyVA9Wk3TPCIqYsRC1Cgb21aDNlrWOyPuLb2OvGGy2DRlQVLDsHaNTyP0dgYNdoUmr2QEMqmZmrvJAmD6Qw4ibpe5e7hHDhtomDwrtoPeny3JpwWo9EXWm0LLYFfKeQI2uBKkZD603uvY= -services: - - redis-server - - postgresql -before_install: - - sudo sed -i -e '/local.*peer/s/postgres/all/' -e 's/peer\|md5/trust/g' /etc/postgresql/*/main/pg_hba.conf - - sudo systemctl restart postgresql@13-main -install: - - pip install -U pip wheel - - pip install -r requirements.txt - - pip install -r requirements_test.txt - - pip install idna --upgrade - - make -before_script: - - sudo -- sh -c "echo '127.0.0.1 funnel.test' >> /etc/hosts" - - sudo -- sh -c "echo '127.0.0.1 f.test' >> /etc/hosts" - - psql -c 'create database funnel_testing;' -U postgres - - 'flask dbconfig | sudo -u postgres psql funnel_testing' - - psql -c 'create database geoname_testing;' -U postgres - - 'flask dbconfig | sudo -u postgres psql geoname_testing' -script: - - 'pytest' - # - './runfrontendtests.sh' -after_success: - - coveralls -notifications: - email: false - slack: - - hasgeek:HDCoMDj3T4ICB59qFFVorCG8 - - friendsofhasgeek:3bLViYSzhfaThJovFYCVD3fX diff --git a/Dockerfile b/Dockerfile index c9b8ce8c4..9693027e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,112 @@ -FROM python:3.9-slim-bullseye +# syntax=docker/dockerfile:1.4 -RUN apt-get -y update +FROM nikolaik/python-nodejs:python3.11-nodejs20-bullseye as base -# install curl -RUN apt-get -y install curl +# https://github.com/zalando/postgres-operator/blob/master/docker/logical-backup/Dockerfile +# https://stackoverflow.com/questions/68465355/what-is-the-meaning-of-set-o-pipefail-in-bash-script +SHELL ["/bin/bash", "-o", "pipefail", "-c"] -# get install script and pass it to execute: -RUN curl -sL https://deb.nodesource.com/setup_14.x | bash +LABEL Name=Funnel +LABEL Version=0.1 -# and install node -RUN apt-get -y install nodejs git wget unzip build-essential make postgresql libpq-dev python-dev +USER pn +RUN \ + mkdir -pv /home/pn/.cache/pip /home/pn/.npm /home/pn/tmp /home/pn/app /home/pn/app/coverage && \ + chown -R pn:pn /home/pn/.cache /home/pn/.npm /home/pn/tmp /home/pn/app /home/pn/app/coverage +EXPOSE 3000 +WORKDIR /home/pn/app -# We don't want to run our application as root if it is not strictly necessary, even in a container. -# Create a user and a group called 'app' to run the processes. -# A system user is sufficient and we do not need a home. +ENV PATH "$PATH:/home/pn/.local/bin" -RUN adduser --system --group --no-create-home app +FROM base as devtest_base +USER root +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update -yqq && \ + apt-get install -yqq --no-install-recommends lsb-release && \ + sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \ + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \ + apt-get update -yqq && apt-get upgrade -yqq && \ + echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections && \ + DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends firefox-esr postgresql-client-15 && \ + cd /tmp/ && \ + curl -fsSL $(curl -fsSL https://api.github.com/repos/mozilla/geckodriver/releases/latest | grep browser_download_url | grep 'linux64.tar.gz\"'| grep -o 'http.*\.gz') > gecko.tar.gz && \ + tar -xvzf gecko.tar.gz && \ + rm gecko.tar.gz && \ + chmod +x geckodriver && \ + mv geckodriver /usr/local/bin && \ + apt-get autoclean -yqq && \ + apt-get autoremove -yqq && \ + cd /home/pn/app +USER pn -# Make the directory the working directory for subsequent commands -WORKDIR app +FROM base as assets +COPY --chown=pn:pn package.json package.json +COPY --chown=pn:pn package-lock.json package-lock.json +RUN --mount=type=cache,target=/root/.npm \ + --mount=type=cache,target=/home/pn/.npm,uid=1000,gid=1000 npm ci +COPY --chown=pn:pn ./funnel/assets ./funnel/assets +COPY --chown=pn:pn .eslintrc.js .eslintrc.js +COPY --chown=pn:pn webpack.config.js webpack.config.js +RUN --mount=type=cache,target=/root/.npm \ + --mount=type=cache,target=/home/pn/.npm,uid=1000,gid=1000 npm run build -# Place the application components in a dir below the root dir -COPY . /app/ +FROM base as dev_assets +COPY --chown=pn:pn package.json package.json +COPY --chown=pn:pn package-lock.json package-lock.json +RUN --mount=type=cache,target=/root/.npm \ + --mount=type=cache,target=/home/pn/.npm,uid=1000,gid=1000 npm install +COPY --chown=pn:pn ./funnel/assets ./funnel/assets +COPY --chown=pn:pn .eslintrc.js .eslintrc.js +COPY --chown=pn:pn webpack.config.js webpack.config.js +RUN --mount=type=cache,target=/root/.npm \ + --mount=type=cache,target=/home/pn/.npm,uid=1000,gid=1000 npx webpack --mode development --progress -RUN cd /app/funnel; make +FROM base as deps +COPY --chown=pn:pn Makefile Makefile +RUN make deps-editable +COPY --chown=pn:pn requirements/base.txt requirements/base.txt +RUN --mount=type=cache,target=/home/pn/.cache/pip,uid=1000,gid=1000 \ + pip install --upgrade pip && \ + pip install --use-pep517 -r requirements/base.txt -# Install from the requirements.txt we copied above -COPY requirements.txt /tmp -RUN pip install -r requirements.txt -COPY . /tmp/myapp -RUN pip install /tmp/myapp +FROM devtest_base as test_deps +COPY --chown=pn:pn Makefile Makefile +RUN make deps-editable +COPY --chown=pn:pn requirements/base.txt requirements/base.txt +COPY --chown=pn:pn requirements/test.txt requirements/test.txt +RUN --mount=type=cache,target=/home/pn/.cache/pip,uid=1000,gid=1000 pip install --use-pep517 -r requirements/test.txt -# Hand everything over to the 'app' user -RUN chown -R app:app /app +FROM devtest_base as dev_deps +COPY --chown=pn:pn Makefile Makefile +RUN make deps-editable +COPY --chown=pn:pn requirements requirements +RUN --mount=type=cache,target=/home/pn/.cache/pip,uid=1000,gid=1000 pip install --use-pep517 -r requirements/dev.txt +COPY --from=dev_assets --chown=pn:pn /home/pn/app/node_modules /home/pn/app/node_modules -# Subsequent commands, either in this Dockerfile or in a -# docker-compose.yml, will run as user 'app' -USER app +FROM deps as production +COPY --chown=pn:pn . . +COPY --chown=pn:pn --from=assets /home/pn/app/funnel/static /home/pn/app/funnel/static +ENTRYPOINT ["uwsgi", "--ini"] +FROM production as supervisor +USER root +RUN \ + apt-get update -yqq && \ + apt-get install -yqq --no-install-recommends supervisor && \ + apt-get autoclean -yqq && \ + apt-get autoremove -yqq && \ + mkdir -pv /var/log/supervisor +COPY ./docker/supervisord/supervisord.conf /etc/supervisor/supervisord.conf +# COPY ./docker/uwsgi/emperor.ini /etc/uwsgi/emperor.ini +ENTRYPOINT ["/usr/bin/supervisord"] -# We are done with setting up the image. -# As this image is used for different -# purposes and processes no CMD or ENTRYPOINT is specified here, -# this is done in docker-compose.yml. +FROM test_deps as test +ENV PWD=/home/pn/app +COPY --chown=pn:pn . . + +COPY --chown=pn:pn --from=assets /home/pn/app/funnel/static /home/pn/app/funnel/static +ENTRYPOINT ["/home/pn/app/docker/entrypoints/ci-test.sh"] +FROM dev_deps as dev +RUN --mount=type=cache,target=/home/pn/.cache/pip,uid=1000,gid=1000 cp -R /home/pn/.cache/pip /home/pn/tmp/.cache_pip +RUN mv /home/pn/tmp/.cache_pip /home/pn/.cache/pip +COPY --chown=pn:pn --from=dev_assets /home/pn/app/funnel/static /home/pn/app/funnel/static diff --git a/Makefile b/Makefile index 426c50247..2d35005d0 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,30 @@ -all: assets - -assets: - npm install - npm run build - -build: - npm run build - -babel: babelpy babeljs +all: + @echo "You must have an active Python virtualenv (3.11+) before using any of these." + @echo + @echo "For production deployment:" + @echo " make install # For first time setup and after dependency upgrades" + @echo " make assets # For only Node asset changes" + @echo + @echo "For testing and CI:" + @echo " make install-test # Install everything needed for a test environment" + @echo + @echo "For development:" + @echo " make install-dev # For first time setup and after dependency upgrades" + @echo " make deps-noup # Rebuild for dependency changes, but skip upgrades" + @echo " make deps # Scan for dependency upgrades (remember to test!)" + @echo " make deps-python # Scan for Python dependency upgrades" + @echo " make deps-npm # Scan for NPM dependency upgrades" + @echo + @echo "To upgrade dependencies in a development environment, use all in order and" + @echo "commit changes only if all tests pass:" + @echo + @echo " make deps" + @echo " make install-dev" + @echo " pytest" babelpy: ZXCVBN_DIR=`python -c "import zxcvbn; import pathlib; print(pathlib.Path(zxcvbn.__file__).parent, end='')"` - pybabel extract -F babel.cfg -k _ -k __ -k ngettext -o funnel/translations/messages.pot . ${ZXCVBN_DIR} + pybabel extract -F babel.cfg -k _ -k __ -k _n -k __n -k gettext -k ngettext -o funnel/translations/messages.pot funnel ${ZXCVBN_DIR} pybabel update -N -i funnel/translations/messages.pot -d funnel/translations pybabel compile -f -d funnel/translations @@ -21,25 +34,104 @@ babeljs: baseframe_dir = $(flask baseframe_translations_path) babeljs: @mkdir -p $(target_dir) - ls $(source_dir) | grep -E '[[:lower:]]{2}_[[:upper:]]{2}' | xargs -I % sh -c 'mkdir -p $(target_dir)/% && ./node_modules/.bin/po2json --format=jed --pretty $(source_dir)/%/LC_MESSAGES/messages.po $(target_dir)/%/messages.json' - ls $(baseframe_dir) | grep -E '[[:lower:]]{2}_[[:upper:]]{2}' | xargs -I % sh -c './node_modules/.bin/po2json --format=jed --pretty $(baseframe_dir)/%/LC_MESSAGES/baseframe.po $(target_dir)/%/baseframe.json' + ls $(source_dir) | grep -E '[[:lower:]]{2}_[[:upper:]]{2}' | xargs -I % sh -c 'mkdir -p $(target_dir)/% && ./node_modules/.bin/po2json --format=jed --pretty --domain=messages $(source_dir)/%/LC_MESSAGES/messages.po $(target_dir)/%/messages.json' + ls $(baseframe_dir) | grep -E '[[:lower:]]{2}_[[:upper:]]{2}' | xargs -I % sh -c './node_modules/.bin/po2json --format=jed --pretty --domain=baseframe $(baseframe_dir)/%/LC_MESSAGES/baseframe.po $(target_dir)/%/baseframe.json' ./node_modules/.bin/prettier --write $(target_dir)/**/**.json -deps: deps-main deps-test deps-dev +babel: babelpy babeljs + +docker-bases: docker-base docker-base-devtest + +docker-base: + docker buildx build -f docker/images/bases.Dockerfile --target base --tag hasgeek/funnel-base . + +docker-base-devtest: + docker buildx build -f docker/images/bases.Dockerfile --target base-devtest --tag hasgeek/funnel-base-devtest . + +docker-ci-test: + COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 BUILDKIT_PROGRESS=plain \ + docker compose --profile test up --quiet-pull --no-attach db-test --no-attach redis-test --no-log-prefix + +docker-dev: + COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 \ + docker compose --profile dev up --abort-on-container-exit --build --force-recreate --no-attach db-dev --no-attach redis-dev --remove-orphans + +deps-editable: DEPS = coaster baseframe +deps-editable: + @if [ ! -d "build" ]; then mkdir build; fi; + @if [ ! -d "build/dependencies" ]; then mkdir build/dependencies; fi; + @cd build/dependencies;\ + for dep in $(DEPS); do\ + if [ -e "$$dep" ]; then\ + echo "Updating $$dep...";\ + echo `cd $$dep;git pull;`;\ + else\ + echo "Cloning dependency $$dep as locally editable installation...";\ + git clone https://github.com/hasgeek/$$dep.git;\ + fi;\ + done; + +deps-python: deps-editable + pip install --upgrade pip pip-tools pip-compile-multi + pip-compile-multi --backtracking --use-cache + +deps-python-noup: + pip-compile-multi --backtracking --use-cache --no-upgrade + +deps-python-rebuild: deps-editable + pip-compile-multi --backtracking --live -deps-main: - pip-compile --upgrade --output-file requirements.txt requirements.in +deps-python-base: deps-editable + pip-compile-multi -t requirements/base.in --backtracking --use-cache -deps-test: - pip-compile --upgrade --output-file requirements_test.txt requirements.in requirements_test.in +deps-python-test: deps-editable + pip-compile-multi -t requirements/test.in --backtracking --use-cache -deps-dev: - pip-compile --upgrade --output-file requirements_dev.txt requirements.in requirements_test.in requirements_dev.in +deps-python-dev: deps-editable + pip-compile-multi -t requirements/dev.in --backtracking --use-cache -tests-data: tests-data-markdown +deps-python-verify: + pip-compile-multi verify -tests-data-markdown: - pytest -v -m update_markdown_data +deps-npm: + npm update + +deps-noup: deps-python-noup + +deps: deps-python deps-npm + +install-npm: + npm install + +install-npm-ci: + npm clean-install + +install-python-pip: + pip install --upgrade pip + +install-python-dev: install-python-pip deps-editable + pip install --use-pep517 -r requirements/dev.txt + +install-python-test: install-python-pip deps-editable + pip install --use-pep517 -r requirements/test.txt + +install-python: install-python-pip deps-editable + pip install --use-pep517 -r requirements/base.txt + +install-dev: deps-editable install-python-dev install-npm assets + +install-test: deps-editable install-python-test install-npm assets + +install: deps-editable install-python install-npm-ci assets + +assets: + npm run build + +assets-dev: + npm run build-dev debug-markdown-tests: pytest -v -m debug_markdown_output + +tests-bdd: + pytest --generate-missing --feature tests tests diff --git a/README.rst b/README.rst index 81985a0d2..31dad39ad 100644 --- a/README.rst +++ b/README.rst @@ -1,19 +1,17 @@ Hasgeek -======= +------- Code for Hasgeek.com at https://hasgeek.com/ -Copyright © 2010-2022 by Hasgeek +Copyright © 2010-2023 by Hasgeek This code is open source under the AGPL v3 license (see LICENSE.txt). We welcome your examination of our code to: * Establish trust and transparency on how it works, and * Allow contributions -To establish our intent, we use the AGPL v3 license, which requires you to release all your modifications to the public under the same license. You may not make a proprietary fork. To have your contributions merged back into the master repository, you must agree to assign copyright to Hasgeek, and must assert that you have the right to make this assignment. (We realise this sucks, so if you have a better idea, we’d like to hear it.) +To establish our intent, we use the AGPL v3 license, which requires you to release your modifications to the public under the same license. You may not make a proprietary fork. To have your contributions merged into the main repository, you must agree to assign copyright to Hasgeek, and must assert that you have the right to make this assignment. You will be asked to sign a Contributor License Agreement when you make a Pull Request. -Our workflow assumes this code is for use on a single production website. Using this to operate your own website is not recommended. Brand names and visual characteristics are not covered under the source code license. +Our workflow assumes this code is for use on a single production website. Using this to operate your own website is not recommended. Brand names, logos and visual characteristics are not covered under the source code license. We aim to have our source code useful to the larger community. Several key components are delegated to the Coaster library, available under the BSD license. Requests for liberal licensing of other components are also welcome. Please file an issue ticket. - -This repository uses Travis CI for test automation and has dependencies scanned by PyUp.io. diff --git a/babel.cfg b/babel.cfg index 78078e530..643369a24 100644 --- a/babel.cfg +++ b/babel.cfg @@ -7,4 +7,4 @@ extensions=jinja2.ext.do,webassets.ext.jinja2.AssetsExtension [javascript: **/assets/js/**.js] [javascript: **/static/js/**.js] encoding = utf-8 -extract_messages=gettext,ngettext +extract_messages=_,__,_n,__n,gettext,ngettext diff --git a/ci.Dockerfile b/ci.Dockerfile new file mode 100644 index 000000000..9b4d5b640 --- /dev/null +++ b/ci.Dockerfile @@ -0,0 +1,17 @@ +# syntax=docker/dockerfile:1.4 + +# Dockerfile syntax & features documentation: +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md + +FROM hasgeek/funnel-base-devtest +LABEL name="FunnelCI" version="0.1" +USER 1000:1000 +RUN \ + mkdir -pv /home/pn/.npm /home/pn/app/node_modules /home/pn/.cache/pip \ + /home/pn/app/coverage /home/pn/.local && \ + chown -R 1000:1000 /home/pn/.npm /home/pn/app /home/pn/.cache \ + /home/pn/app/coverage /home/pn/.local + +WORKDIR /home/pn/app +COPY --chown=pn:pn . . +ENTRYPOINT [ "/home/pn/app/docker/entrypoints/ci-test.sh" ] diff --git a/devserver.py b/devserver.py index 75f000660..55800a1c0 100755 --- a/devserver.py +++ b/devserver.py @@ -3,16 +3,28 @@ import os import sys +from typing import Any +from flask.cli import load_dotenv from werkzeug import run_simple +from coaster.utils import getbool + + +def rq_background_worker(*args: Any, **kwargs: Any) -> Any: + """Import, create and start a new RQ worker in the background process.""" + from funnel import rq # pylint: disable=import-outside-toplevel + + return rq.get_worker().work(*args, **kwargs) + + if __name__ == '__main__': + load_dotenv() sys.path.insert(0, os.path.dirname(__file__)) os.environ['FLASK_ENV'] = 'development' # Needed for coaster.app.init_app os.environ.setdefault('FLASK_DEBUG', '1') - debug_mode = not os.environ['FLASK_DEBUG'].lower() in {'0', 'false', 'no'} + debug_mode = os.environ['FLASK_DEBUG'].lower() not in {'0', 'false', 'no'} - from funnel import rq from funnel.devtest import BackgroundWorker, devtest_app # Set debug mode on apps @@ -21,7 +33,10 @@ background_rq = None if os.environ.get('WERKZEUG_RUN_MAIN') == 'true': # Only start RQ worker within the reloader environment - background_rq = BackgroundWorker(rq.get_worker().work) + background_rq = BackgroundWorker( + rq_background_worker, + mock_transports=bool(getbool(os.environ.get('MOCK_TRANSPORTS', True))), + ) background_rq.start() run_simple( diff --git a/docker-compose.yml b/docker-compose.yml index 42b8a9a9d..3c9b0d1d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,39 +1,210 @@ -version: '3' - +name: funnel +x-postgres: &postgres + image: postgres:latest + restart: always + user: postgres + environment: + - POSTGRES_HOST_AUTH_METHOD=trust + - POSTGRES_USER=postgres + expose: + - 5432 + healthcheck: + interval: 5s + timeout: 5s + retries: 5 +x-redis: &redis + image: redis:latest + expose: + - 6379 + restart: always + healthcheck: + test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping'] +x-app: &app + extends: + file: docker/compose/services.yml + service: funnel-prod + build: + context: . + target: production + image: funnel + profiles: + - production + depends_on: + - redis + environment: + - REDIS_HOST=redis +x-test: &test-app + extends: + file: docker/compose/services.yml + service: funnel + image: funnel-test + profiles: + - test + build: + context: . + dockerfile: ci.Dockerfile + working_dir: /home/pn/app + user: pn + volumes: + - ./.cache/.npm:/home/pn/.npm + - ./.cache/node_modules:/home/pn/app/node_modules + - ./.cache/pip:/home/pn/.cache/pip + - ./.cache/.local:/home/pn/.local + - ./coverage:/home/pn/app/coverage + restart: 'no' services: - web: - build: . - image: master-image + app: + <<: *app + volumes: + - ./instance/settings.py:/home/pn/app/instance/settings.py + - ./docker/uwsgi/funnel.ini:/home/pn/funnel.ini:ro + command: ../funnel.ini + expose: + - 6400 ports: - - 3000:3000 + - 6400:6400 + pre-test: + <<: *test-app + user: root + entrypoint: ['/home/pn/app/docker/entrypoints/ci-pre-test.sh'] + test: + <<: *test-app depends_on: - - redis - command: sh ./devserver.py + pre-test: + condition: service_completed_successfully + redis-test: + condition: service_healthy + db-test: + condition: service_healthy + links: + - db-test + - redis-test + environment: + - REDIS_HOST=redis-test + - DB_HOST=db-test + - FLASK_SQLALCHEMY_DATABASE_URI=postgresql+psycopg://funnel@db-test/funnel_testing + - FLASK_SQLALCHEMY_BINDS__geoname=postgresql+psycopg://funnel@db-test/geoname_testing + db-test: + <<: *postgres + profiles: + - test + volumes: + - postgres_test:/var/lib/postgresql/data + - ./docker/initdb/test.sh:/docker-entrypoint-initdb.d/test.sh:ro + healthcheck: + test: ['CMD-SHELL', 'psql funnel_testing'] + redis-test: + <<: *redis + profiles: + - test volumes: - #- .:/app - - $HOME/.aws/credentials:/app/.aws/credentials:ro - worker: - image: master-image + - redis_test:/data + dev: + extends: + file: docker/compose/services.yml + service: funnel + image: funnel-dev + container_name: funnel-dev + profiles: + - dev + - dev-no-watch + build: + context: . + target: dev depends_on: - - redis - command: sh ./rq.sh + redis-dev: + condition: service_healthy + db-dev: + condition: service_healthy + working_dir: /home/pn/app + entrypoint: /home/pn/dev-entrypoint.sh + ports: + - 3000:3000 + links: + - db-dev + - redis-dev volumes: - - $HOME/.aws/credentials:/app/.aws/credentials:ro - redis: - image: redis - nginx: + # https://stackoverflow.com/questions/43844639/how-do-i-add-cached-or-delegated-into-a-docker-compose-yml-volumes-list + # https://forums.docker.com/t/what-happened-to-delegated-cached-ro-and-other-flags/105097/2 + - pip_cache:/home/pn/.cache/pip:delegated + - .:/home/pn/app + - node_modules:/home/pn/app/node_modules + - ./docker/entrypoints/dev.sh:/home/pn/dev-entrypoint.sh:ro + - ./instance/settings.py:/home/pn/app/instance/settings.py + environment: + - DB_HOST=db-dev + - POSTGRES_USER_HOST=funnel@db-dev + - REDIS_HOST=redis-dev + healthcheck: + test: bash -c '[[ "$$(curl -o /dev/null -s -w "%{http_code}\n" http://funnel.test:3000)" == "200" ]]' + interval: 30s + timeout: 1m + retries: 10 + start_period: 30s + asset-watcher: + extends: + file: docker/compose/services.yml + service: funnel + image: funnel-dev-asset-watcher + container_name: funnel-dev-asset-watcher + profiles: + - dev build: - context: ./etc/nginx - container_name: nginx + context: . + target: dev-assets + working_dir: /home/pn/app + entrypoint: npx webpack --mode development --watch volumes: - - static_data:/vol/static - # - /etc/letsencrypt:/etc/letsencrypt - # - ./etc/letsencrypt/www:/var/www/letsencrypt - ports: - - 80:80 - - 443:443 - command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;error_log /dev/stdout info;"''' + - .:/home/pn/app + - node_modules:/home/pn/app/node_modules + environment: + - NODE_ENV=development depends_on: - - web + dev: + condition: service_healthy + healthcheck: + test: bash -c "[[ -f /home/pn/app/funnel/static/build/manifest.json ]]" + interval: 10s + timeout: 30s + retries: 60 + start_period: 1m + db-dev: + <<: *postgres + profiles: + - dev + - dev-no-watch + volumes: + - postgres_dev:/var/lib/postgresql/data + - ./docker/initdb/dev.sh:/docker-entrypoint-initdb.d/dev.sh:ro + healthcheck: + test: ['CMD-SHELL', 'psql funnel'] + redis-dev: + <<: *redis + profiles: + - dev + - dev-no-watch + volumes: + - redis_dev:/data + redis: + <<: *redis + profiles: + - production + volumes: + - redis:/data +x-tmpfs: &tmpfs + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: 'uid=999,gid=999' # uid:gid is 999:999 for both postgres and redis + volumes: - static_data: + node_modules: + pip_cache: + postgres_dev: + redis_dev: + redis: + postgres_test: + <<: *tmpfs + redis_test: + <<: *tmpfs diff --git a/docker/.npmrc b/docker/.npmrc new file mode 100644 index 000000000..7a650526c --- /dev/null +++ b/docker/.npmrc @@ -0,0 +1,4 @@ +audit = false +fund = false +loglevel = warn +update-notifier = false diff --git a/docker/compose/services.yml b/docker/compose/services.yml new file mode 100644 index 000000000..4bc90056e --- /dev/null +++ b/docker/compose/services.yml @@ -0,0 +1,20 @@ +services: + funnel: + logging: + driver: json-file + options: + max-size: 200k + max-file: 10 + extra_hosts: + - 'funnel.test:127.0.0.1' + - 'f.test:127.0.0.1' + environment: + - FLASK_RUN_HOST=0.0.0.0 + funnel-prod: + extends: + file: services.yml + service: funnel + build: + target: production + links: + - redis diff --git a/docker/entrypoints/ci-pre-test.sh b/docker/entrypoints/ci-pre-test.sh new file mode 100755 index 000000000..29a558f0a --- /dev/null +++ b/docker/entrypoints/ci-pre-test.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +# https://github.com/docker/for-mac/issues/5480 + +chown -R pn:pn /home/pn/.npm /home/pn/.cache /home/pn/.cache/pip /home/pn/app \ + /home/pn/app/coverage /home/pn/.local diff --git a/docker/entrypoints/ci-test.sh b/docker/entrypoints/ci-test.sh new file mode 100755 index 000000000..4da374e54 --- /dev/null +++ b/docker/entrypoints/ci-test.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +make install-test +pytest --allow-hosts=127.0.0.1,::1,$(hostname -i),$(getent ahosts db-test | awk '/STREAM/ { print $1}'),$(getent ahosts redis-test | awk '/STREAM/ { print $1}') --gherkin-terminal-reporter -vv --showlocals --cov=funnel +coverage lcov -o coverage/funnel.lcov diff --git a/docker/entrypoints/dev.sh b/docker/entrypoints/dev.sh new file mode 100755 index 000000000..9135c8ff1 --- /dev/null +++ b/docker/entrypoints/dev.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +if [ "$(psql -XtA -U postgres -h $DB_HOST funnel -c "select count(*) from information_schema.tables where table_schema = 'public';")" = "0" ]; then + flask dbcreate + flask db stamp +fi + +./devserver.py diff --git a/docker/images/bases.Dockerfile b/docker/images/bases.Dockerfile new file mode 100644 index 000000000..9e27b0cf7 --- /dev/null +++ b/docker/images/bases.Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1.4 + +# Dockerfile syntax & features documentation: +# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md + +FROM nikolaik/python-nodejs:python3.11-nodejs20-bullseye as base + +# https://github.com/zalando/postgres-operator/blob/master/docker/logical-backup/Dockerfile +# https://stackoverflow.com/questions/68465355/what-is-the-meaning-of-set-o-pipefail-in-bash-script +SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] + +STOPSIGNAL SIGINT +ENV PATH "$PATH:/home/pn/.local/bin" + +# Install postgresql-client-15 +USER root:root +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update -y \ + && apt-get install -y --no-install-recommends lsb-release \ + && echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \ + && apt-get update -y && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends postgresql-client-15 \ + && apt-get purge -y lsb-release +RUN mkdir -pv /var/cache/funnel && chown -R pn:pn /var/cache/funnel +USER pn:pn + +FROM base as base-devtest +# Install firefox & geckodriver +USER root:root +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update -y \ + && apt-get upgrade -y \ + && echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends firefox-esr \ + && cd /tmp/ \ + && curl -fsSL $(curl -fsSL https://api.github.com/repos/mozilla/geckodriver/releases/latest | grep browser_download_url | grep 'linux64.tar.gz\"'| grep -o 'http.*\.gz') > gecko.tar.gz \ + && tar -xvzf gecko.tar.gz \ + && rm gecko.tar.gz \ + && chmod +x geckodriver \ + && mv geckodriver /usr/local/bin +USER pn:pn diff --git a/docker/initdb/dev.sh b/docker/initdb/dev.sh new file mode 100755 index 000000000..4cc3bf36d --- /dev/null +++ b/docker/initdb/dev.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +psql -c "create user funnel;" +psql -c "create database funnel;" +psql -c "create database geoname;" +psql -c "create database funnel_testing;" +psql -c "create database geoname_testing;" +psql funnel << $$ +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS unaccent; +CREATE EXTENSION IF NOT EXISTS pgcrypto; +$$ +psql geoname << $$ +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS unaccent; +CREATE EXTENSION IF NOT EXISTS pgcrypto; +$$ + +psql funnel_testing << $$ +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS unaccent; +CREATE EXTENSION IF NOT EXISTS pgcrypto; +$$ +psql geoname_testing << $$ +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS unaccent; +CREATE EXTENSION IF NOT EXISTS pgcrypto; +$$ + +psql -c "grant all privileges on database funnel to funnel;" +psql -c "grant all privileges on database geoname to funnel;" +psql funnel -c "grant all privileges on schema public to funnel; grant all privileges on all tables in schema public to funnel; grant all privileges on all sequences in schema public to funnel;" +psql geoname -c "grant all privileges on schema public to funnel; grant all privileges on all tables in schema public to funnel; grant all privileges on all sequences in schema public to funnel;" + +psql -c "grant all privileges on database funnel_testing to funnel;" +psql -c "grant all privileges on database geoname_testing to funnel;" +psql funnel_testing -c "grant all privileges on schema public to funnel; grant all privileges on all tables in schema public to funnel; grant all privileges on all sequences in schema public to funnel;" +psql geoname_testing -c "grant all privileges on schema public to funnel; grant all privileges on all tables in schema public to funnel; grant all privileges on all sequences in schema public to funnel;" diff --git a/docker/initdb/test.sh b/docker/initdb/test.sh new file mode 100755 index 000000000..2d7e0962f --- /dev/null +++ b/docker/initdb/test.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +psql -c "create user funnel;" +psql -c "create database funnel_testing;" +psql -c "create database geoname_testing;" +psql funnel_testing << $$ +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS unaccent; +CREATE EXTENSION IF NOT EXISTS pgcrypto; +$$ +psql geoname_testing << $$ +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS unaccent; +CREATE EXTENSION IF NOT EXISTS pgcrypto; +$$ +psql -c "grant all privileges on database funnel_testing to funnel;" +psql -c "grant all privileges on database geoname_testing to funnel;" +psql funnel_testing -c "grant all privileges on schema public to funnel; grant all privileges on all tables in schema public to funnel; grant all privileges on all sequences in schema public to funnel;" +psql geoname_testing -c "grant all privileges on schema public to funnel; grant all privileges on all tables in schema public to funnel; grant all privileges on all sequences in schema public to funnel;" diff --git a/docker/supervisord/supervisord.conf b/docker/supervisord/supervisord.conf new file mode 100644 index 000000000..afead1552 --- /dev/null +++ b/docker/supervisord/supervisord.conf @@ -0,0 +1,28 @@ +; supervisor config file + +[unix_http_server] +file=/var/run/supervisor.sock ; (the path to the socket file) +chmod=0700 ; sockef file mode (default 0700) + +[supervisord] +logfile=/var/log/supervisor/supervisord.log ; (main log file;default $CWD/supervisord.log) +pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid) +childlogdir=/var/log/supervisor ; ('AUTO' child log dir, default $TEMP) + +; the below section must remain in the config file for RPC +; (supervisorctl/web interface) to work, additional interfaces may be +; added by defining them in separate rpcinterface: sections +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock ; use a unix:// URL for a unix socket + +; The [include] section can just contain the "files" setting. This +; setting can list multiple files (separated by whitespace or +; newlines). It can also contain wildcards. The filenames are +; interpreted as relative to this file. Included files *cannot* +; include files themselves. + +[include] +files = /etc/supervisor/conf.d/*.conf diff --git a/docker/uwsgi/emperor.ini b/docker/uwsgi/emperor.ini new file mode 100644 index 000000000..5b0723087 --- /dev/null +++ b/docker/uwsgi/emperor.ini @@ -0,0 +1,6 @@ +[uwsgi] +emperor = /etc/uwsgi/apps-enabled +emperor-tyrant = false +uid = root +gid = root +cap = setgid,setuid diff --git a/docker/uwsgi/funnel.ini b/docker/uwsgi/funnel.ini new file mode 100644 index 000000000..857590750 --- /dev/null +++ b/docker/uwsgi/funnel.ini @@ -0,0 +1,12 @@ +[uwsgi] +socket = 0.0.0.0:6400 +processes = 6 +threads = 2 +master = true +uid = funnel +gid = funnel +chdir = /home/pn/app +wsgi-file = wsgi.py +callable = application +buffer-size = 24000 +pidfile = /home/pn/%n.pid diff --git a/etc/nginx-staging/Dockerfile b/etc/nginx-staging/Dockerfile new file mode 100644 index 000000000..d294d055f --- /dev/null +++ b/etc/nginx-staging/Dockerfile @@ -0,0 +1,13 @@ +FROM nginx:latest + +RUN rm /etc/nginx/conf.d/default.conf +COPY ./default.conf /etc/nginx/conf.d +COPY ./uwsgi_params /etc/nginx/uwsgi_params + +USER root + +RUN mkdir -p /vol/static +RUN chmod 755 /vol/static + + +#USER nginx diff --git a/etc/nginx-staging/default.conf b/etc/nginx-staging/default.conf new file mode 100644 index 000000000..50b5da9f8 --- /dev/null +++ b/etc/nginx-staging/default.conf @@ -0,0 +1,43 @@ + + upstream flask { + # FIXME: This refers to a Flask development server. It should use uwsgi. + server web:3000; + } + + server { + # TODO: Support HTTPS serving for local testing + listen 80; + listen [::]:80; + server_name hasgeek.dev; + + client_max_body_size 4G; + keepalive_timeout 5; + client_body_timeout 300s; + + if ($http_x_forwarded_proto != 'https') { + return 301 https://$server_name$request_uri; + } + + # TODO: Add serverdown fallback + + # Proxy connections to flask + location / { + include uwsgi_params; + uwsgi_pass http://flask; + uwsgi_read_timeout 60s; + uwsgi_send_timeout 60s; + uwsgi_connect_timeout 60s; + #proxy_pass http://flask; + #proxy_redirect off; + #proxy_set_header Host $host; + #proxy_set_header X-Real-IP $remote_addr; + #proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + #proxy_set_header X-Forwarded-Proto $scheme; + #proxy_set_header X-Forwarded-Host $host; + #proxy_set_header X-Forwarded-Port $server_port; + #proxy_ignore_client_abort on; + #proxy_connect_timeout 100; + #proxy_send_timeout 150; + #proxy_read_timeout 200; + } + } diff --git a/etc/nginx-staging/legacy_redirects.txt b/etc/nginx-staging/legacy_redirects.txt new file mode 100644 index 000000000..23fd869a6 --- /dev/null +++ b/etc/nginx-staging/legacy_redirects.txt @@ -0,0 +1,33 @@ +# These redirects are used in nginx config to redirect legacy funnel urls. +# Put them in the `server` block of `funnel.hasgeek.com` nginx config. + +rewrite ^/jsfoo2011/(.*)$ https://jsfoo.talkfunnel.com/2011/$1 permanent; +rewrite ^/inboxalert/(.*)$ https://inboxalert.talkfunnel.com/2013-tentative/$1 permanent; +rewrite ^/jsfoo-bangalore2012/(.*)$ https://jsfoo.talkfunnel.com/2012-dummy/$1 permanent; +rewrite ^/inbox-alert-2014/(.*)$ https://inboxalert.talkfunnel.com/2014/$1 permanent; +rewrite ^/jsfoo2013/(.*)$ https://jsfoo.talkfunnel.com/2013/$1 permanent; +rewrite ^/fifthel2013/(.*)$ https://fifthelephant.talkfunnel.com/2013/$1 permanent; +rewrite ^/5el/(.*)$ https://fifthelephant.talkfunnel.com/2012/$1 permanent; +rewrite ^/cartonama/(.*)$ https://cartonama.talkfunnel.com/2012/$1 permanent; +rewrite ^/jsfoo-pune/(.*)$ https://jsfoo.talkfunnel.com/2012-pune/$1 permanent; +rewrite ^/metarefresh/(.*)$ https://metarefresh.talkfunnel.com/2012/$1 permanent; +rewrite ^/jsfoo/(.*)$ https://jsfoo.talkfunnel.com/2012/$1 permanent; +rewrite ^/droidcon2012/(.*)$ https://droidconin.talkfunnel.com/2012/$1 permanent; +rewrite ^/metarefresh2013/(.*)$ https://metarefresh.talkfunnel.com/2013/$1 permanent; +rewrite ^/droidcon/(.*)$ https://droidconin.talkfunnel.com/2011/$1 permanent; +rewrite ^/rootconf/(.*)$ https://rootconf.talkfunnel.com/2012/$1 permanent; +rewrite ^/cartonama-workshop/(.*)$ https://cartonama.talkfunnel.com/2012-workshop/$1 permanent; +rewrite ^/paystation/(.*)$ https://minoconf.talkfunnel.com/paystation/$1 permanent; +rewrite ^/jsfoo-chennai/(.*)$ https://jsfoo.talkfunnel.com/2012-chennai/$1 permanent; +rewrite ^/css-workshop/(.*)$ https://metarefresh.talkfunnel.com/2013-css-workshop/$1 permanent; +rewrite ^/phpcloud/(.*)$ https://phpcloud.talkfunnel.com/2011/$1 permanent; +rewrite ^/fifthel2014/(.*)$ https://fifthelephant.talkfunnel.com/2014/$1 permanent; +rewrite ^/droidcon2014/(.*)$ https://droidconin.talkfunnel.com/2014/$1 permanent; +rewrite ^/jsfoo2014/(.*)$ https://jsfoo.talkfunnel.com/2014/$1 permanent; +rewrite ^/metarefresh2015/(.*)$ https://metarefresh.talkfunnel.com/2015/$1 permanent; +rewrite ^/rootconf2014/(.*)$ https://rootconf.talkfunnel.com/2014/$1 permanent; +rewrite ^/metarefresh2014/(.*)$ https://metarefresh.talkfunnel.com/2014/$1 permanent; +rewrite ^/angularjs-miniconf-2014/(.*)$ https://minoconf.talkfunnel.com/2014-angularjs/$1 permanent; +rewrite ^/droidcon2013/(.*)$ https://droidconin.talkfunnel.com/2013/$1 permanent; +rewrite ^/redis-miniconf-2014/(.*)$ https://minoconf.talkfunnel.com/2014-redis/$1 permanent; +rewrite ^/rootconf-miniconf-2014/(.*)$ https://miniconf.talkfunnel.com/2014-rootconf/$1 permanent; diff --git a/etc/nginx-staging/uwsgi_params b/etc/nginx-staging/uwsgi_params new file mode 100644 index 000000000..52f18342f --- /dev/null +++ b/etc/nginx-staging/uwsgi_params @@ -0,0 +1,15 @@ +uwsgi_param QUERY_STRING $query_string; +uwsgi_param REQUEST_METHOD $request_method; +uwsgi_param CONTENT_TYPE $content_type; +uwsgi_param CONTENT_LENGTH $content_length; + +uwsgi_param REQUEST_URI $request_uri; +uwsgi_param PATH_INFO $document_uri; +uwsgi_param DOCUMENT_ROOT $document_root; +uwsgi_param SERVER_PROTOCOL $server_protocol; +uwsgi_param UWSGI_SCHEME $scheme; + +uwsgi_param REMOTE_ADDR $remote_addr; +uwsgi_param REMOTE_PORT $remote_port; +uwsgi_param SERVER_PORT $server_port; +uwsgi_param SERVER_NAME $server_name; diff --git a/etc/nginx/Dockerfile b/etc/nginx/Dockerfile index 97cf7ed9e..d294d055f 100644 --- a/etc/nginx/Dockerfile +++ b/etc/nginx/Dockerfile @@ -2,6 +2,7 @@ FROM nginx:latest RUN rm /etc/nginx/conf.d/default.conf COPY ./default.conf /etc/nginx/conf.d +COPY ./uwsgi_params /etc/nginx/uwsgi_params USER root diff --git a/etc/nginx/default.conf b/etc/nginx/default.conf index 84eedcca7..6ffb117e9 100644 --- a/etc/nginx/default.conf +++ b/etc/nginx/default.conf @@ -1,14 +1,20 @@ upstream flask { - # FIXME: This refers to a Flask development server. It should use uwsgi. server web:3000; } + #server { + # listen 80; + # server_name funnel.hasgeek.com www.funnel.hasgeek.com funnel.hasgeek.in www.funnel.hasgeek.in; + # return 301 https://funnel.hasgeek.com$request_uri; + #} + server { - # TODO: Support HTTPS serving for local testing listen 80; listen [::]:80; - server_name hasgeek.dev; + server_name beta.hasgeek.com; + + # server_name hasgeek.com www.hasgeek.com hasgeek.in www.hasgeek.in; client_max_body_size 4G; keepalive_timeout 5; @@ -22,17 +28,22 @@ # Proxy connections to flask location / { - proxy_pass http://flask; - proxy_redirect off; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - proxy_ignore_client_abort on; - proxy_connect_timeout 100; - proxy_send_timeout 150; - proxy_read_timeout 200; + include uwsgi_params; + uwsgi_pass http://flask; + uwsgi_read_timeout 60s; + uwsgi_send_timeout 60s; + uwsgi_connect_timeout 60s; + #proxy_pass http://flask; + #proxy_redirect off; + #proxy_set_header Host $host; + #proxy_set_header X-Real-IP $remote_addr; + #proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + #proxy_set_header X-Forwarded-Proto $scheme; + #proxy_set_header X-Forwarded-Host $host; + #proxy_set_header X-Forwarded-Port $server_port; + #proxy_ignore_client_abort on; + #proxy_connect_timeout 100; + #proxy_send_timeout 150; + #proxy_read_timeout 200; } } diff --git a/etc/nginx/uwsgi_params b/etc/nginx/uwsgi_params new file mode 100644 index 000000000..52f18342f --- /dev/null +++ b/etc/nginx/uwsgi_params @@ -0,0 +1,15 @@ +uwsgi_param QUERY_STRING $query_string; +uwsgi_param REQUEST_METHOD $request_method; +uwsgi_param CONTENT_TYPE $content_type; +uwsgi_param CONTENT_LENGTH $content_length; + +uwsgi_param REQUEST_URI $request_uri; +uwsgi_param PATH_INFO $document_uri; +uwsgi_param DOCUMENT_ROOT $document_root; +uwsgi_param SERVER_PROTOCOL $server_protocol; +uwsgi_param UWSGI_SCHEME $scheme; + +uwsgi_param REMOTE_ADDR $remote_addr; +uwsgi_param REMOTE_PORT $remote_port; +uwsgi_param SERVER_PORT $server_port; +uwsgi_param SERVER_NAME $server_name; diff --git a/funnel/__init__.py b/funnel/__init__.py index 54edf12e2..eeef7cee0 100644 --- a/funnel/__init__.py +++ b/funnel/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations -from datetime import timedelta -from typing import cast -import json import logging -import os.path +from datetime import timedelta +from email.utils import parseaddr +import phonenumbers from flask import Flask from flask_babel import get_locale from flask_executor import Executor @@ -16,25 +15,33 @@ from flask_migrate import Migrate from flask_redis import FlaskRedis from flask_rq2 import RQ - from whitenoise import WhiteNoise -import geoip2.database -from baseframe import Bundle, Version, assets, baseframe -from baseframe.blueprint import THEME_FILES import coaster.app +from baseframe import Bundle, Version, __, assets, baseframe +from baseframe.blueprint import THEME_FILES +from coaster.assets import WebpackManifest from ._version import __version__ #: Main app for hasgeek.com app = Flask(__name__, instance_relative_config=True) +app.name = 'funnel' +app.config['SITE_TITLE'] = __("Hasgeek") #: Shortlink app at has.gy shortlinkapp = Flask(__name__, static_folder=None, instance_relative_config=True) +shortlinkapp.name = 'shortlink' +#: Unsubscribe app at bye.li +unsubscribeapp = Flask(__name__, static_folder=None, instance_relative_config=True) +unsubscribeapp.name = 'unsubscribe' + +all_apps = [app, shortlinkapp, unsubscribeapp] mail = Mail() pages = FlatPages() +manifest = WebpackManifest(filepath='static/build/manifest.json') -redis_store = FlaskRedis(decode_responses=True) +redis_store = FlaskRedis(decode_responses=True, config_prefix='CACHE_REDIS') rq = RQ() rq.job_class = 'rq.job.Job' rq.queues = ['funnel'] # Queues used in this app @@ -57,51 +64,69 @@ assets['spectrum.js'][version] = 'js/libs/spectrum.js' assets['spectrum.css'][version] = 'css/spectrum.css' assets['schedules.js'][version] = 'js/schedules.js' -assets['funnel-mui.js'][version] = 'js/libs/mui.js' - -try: - with open( - os.path.join(cast(str, app.static_folder), 'build/manifest.json'), - encoding='utf-8', - ) as built_manifest: - built_assets = json.load(built_manifest) -except OSError: - built_assets = {} - app.logger.error("static/build/manifest.json file missing; run `make`") + # --- Import rest of the app ----------------------------------------------------------- from . import ( # isort:skip # noqa: F401 # pylint: disable=wrong-import-position - models, - signals, - forms, + geoip, + proxies, loginproviders, + signals, + models, transports, + forms, views, cli, - proxies, ) -from .models import db # isort:skip # pylint: disable=wrong-import-position +from .models import db, sa # isort:skip # pylint: disable=wrong-import-position # --- Configuration--------------------------------------------------------------------- -app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=365) -app.config['SESSION_REFRESH_EACH_REQUEST'] = False -coaster.app.init_app(app, ['py', 'toml']) -coaster.app.init_app(shortlinkapp, ['py', 'toml'], init_logging=False) -proxies.init_app(app) -proxies.init_app(shortlinkapp) - -# These are app specific confguration files that must exist -# inside the `instance/` directory. Sample config files are -# provided as example. +# Config is loaded from legacy Python settings files in the instance folder and then +# overridden with values from the environment. Python config is pending deprecation +# All supported config values are listed in ``sample.env``. If an ``.env`` file is +# present, it is loaded in debug and testing modes only +for each_app in all_apps: + coaster.app.init_app( + each_app, ['py', 'env'], env_prefix=['FLASK', f'APP_{each_app.name.upper()}'] + ) + +# Legacy additional config for the main app (pending deprecation) coaster.app.load_config_from_file(app, 'hasgeekapp.py') -shortlinkapp.config['SERVER_NAME'] = app.config['SHORTLINK_DOMAIN'] -# Downgrade logging from default WARNING level to INFO -for _logging_app in (app, shortlinkapp): - if not _logging_app.debug: - _logging_app.logger.setLevel(logging.INFO) +# Force specific config settings, overriding deployment config +shortlinkapp.config['SERVER_NAME'] = app.config['SHORTLINK_DOMAIN'] +if app.config.get('UNSUBSCRIBE_DOMAIN'): + unsubscribeapp.config['SERVER_NAME'] = app.config['UNSUBSCRIBE_DOMAIN'] +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=365) +app.config['SESSION_REFRESH_EACH_REQUEST'] = False +app.config['FLATPAGES_MARKDOWN_EXTENSIONS'] = ['markdown.extensions.nl2br'] +app.config['FLATPAGES_EXTENSION'] = '.md' +app.config['EXECUTOR_PROPAGATE_EXCEPTIONS'] = True +app.config['EXECUTOR_PUSH_APP_CONTEXT'] = True +# Remove legacy asset manifest settings that Baseframe looks for +app.config.pop('ASSET_MANIFEST_PATH', None) +app.config.pop('ASSET_BASE_PATH', None) + +# Install common extensions on all apps +for each_app in all_apps: + # If MAIL_DEFAULT_SENDER is in the form "Name ", extract email + each_app.config['MAIL_DEFAULT_SENDER_ADDR'] = parseaddr( + app.config['MAIL_DEFAULT_SENDER'] + )[1] + each_app.config['SITE_SUPPORT_PHONE_FORMATTED'] = phonenumbers.format_number( + phonenumbers.parse(each_app.config['SITE_SUPPORT_PHONE']), + phonenumbers.PhoneNumberFormat.INTERNATIONAL, + ) + proxies.init_app(each_app) + manifest.init_app(each_app) + db.init_app(each_app) + mail.init_app(each_app) + + # Downgrade logging from default WARNING level to INFO unless in debug mode + if not each_app.debug: + each_app.logger.setLevel(logging.INFO) # TODO: Move this into Baseframe app.jinja_env.globals['get_locale'] = get_locale @@ -109,74 +134,33 @@ # TODO: Replace this with something cleaner. The `login_manager` attr expectation is # from coaster.auth. It attempts to call `current_app.login_manager._load_user`, an # API it borrows from the Flask-Login extension -app.login_manager = views.login_session.LoginManager() - -db.init_app(app) # type: ignore[has-type] -db.init_app(shortlinkapp) # type: ignore[has-type] - -migrate = Migrate(app, db) # type: ignore[has-type] - -mail.init_app(app) -mail.init_app(shortlinkapp) # Required for email error reports +app.login_manager = views.login_session.LoginManager() # type: ignore[attr-defined] app.config['FLATPAGES_MARKDOWN_EXTENSIONS'] = ['markdown.extensions.nl2br'] app.config['FLATPAGES_EXTENSION'] = '.md' +# These extensions are only required in the main app +migrate = Migrate(app, db) pages.init_app(app) - redis_store.init_app(app) - rq.init_app(app) - -app.config['EXECUTOR_PROPAGATE_EXCEPTIONS'] = True -app.config['EXECUTOR_PUSH_APP_CONTEXT'] = True executor.init_app(app) +geoip.geoip.init_app(app) -baseframe.init_app( - app, - requires=['funnel'], - ext_requires=[ - 'pygments', - 'toastr', - 'jquery.cookie', - 'timezone', - 'pace', - 'jquery-modal', - 'select2-material', - 'getdevicepixelratio', - 'jquery.truncate8', - 'funnel-mui', - ], - theme='funnel', - asset_modules=('baseframe_private_assets',), - error_handlers=False, -) +# Baseframe is required for apps with UI ('funnel' theme is registered above) +baseframe.init_app(app, requires=['funnel'], theme='funnel', error_handlers=False) +# Initialize available login providers from app config loginproviders.init_app(app) -# Load GeoIP2 databases -app.geoip_city = None -app.geoip_asn = None -if 'GEOIP_DB_CITY' in app.config: - if not os.path.exists(app.config['GEOIP_DB_CITY']): - app.logger.warning( - "GeoIP city database missing at %s", app.config['GEOIP_DB_CITY'] - ) - else: - app.geoip_city = geoip2.database.Reader(app.config['GEOIP_DB_CITY']) - -if 'GEOIP_DB_ASN' in app.config: - if not os.path.exists(app.config['GEOIP_DB_ASN']): - app.logger.warning( - "GeoIP ASN database missing at %s", app.config['GEOIP_DB_ASN'] - ) - else: - app.geoip_asn = geoip2.database.Reader(app.config['GEOIP_DB_ASN']) +# Ensure FEATURED_ACCOUNTS is a list, not None +if not app.config.get('FEATURED_ACCOUNTS'): + app.config['FEATURED_ACCOUNTS'] = [] # Turn on supported notification transports transports.init() # Register JS and CSS assets on both apps -app.assets.register( +app.assets.register( # type: ignore[attr-defined] 'js_fullcalendar', Bundle( assets.require( @@ -185,13 +169,14 @@ 'moment.js', 'moment-timezone-data.js', 'spectrum.js', + 'toastr.js', 'jquery.ui.sortable.touch.js', ), output='js/fullcalendar.packed.js', - filters='uglipyjs', + filters='rjsmin', ), ) -app.assets.register( +app.assets.register( # type: ignore[attr-defined] 'css_fullcalendar', Bundle( assets.require('jquery.fullcalendar.css', 'spectrum.css'), @@ -199,18 +184,20 @@ filters='cssmin', ), ) -app.assets.register( +app.assets.register( # type: ignore[attr-defined] 'js_schedules', Bundle( assets.require('schedules.js'), output='js/schedules.packed.js', - filters='uglipyjs', + filters='rjsmin', ), ) +views.siteadmin.init_rq_dashboard() + # --- Serve static files with Whitenoise ----------------------------------------------- -app.wsgi_app = WhiteNoise( # type: ignore[assignment] +app.wsgi_app = WhiteNoise( # type: ignore[method-assign] app.wsgi_app, root=app.static_folder, prefix=app.static_url_path ) app.wsgi_app.add_files( # type: ignore[attr-defined] @@ -221,4 +208,4 @@ # Database model loading (from Funnel or extensions) is complete. # Configure database mappers now, before the process is forked for workers. -db.configure_mappers() # type: ignore[has-type] +sa.orm.configure_mappers() diff --git a/funnel/assets/js/account_form.js b/funnel/assets/js/account_form.js index de7f4f3e6..3eae8eb82 100644 --- a/funnel/assets/js/account_form.js +++ b/funnel/assets/js/account_form.js @@ -1,4 +1,5 @@ import 'htmx.org'; +import toastr from 'toastr'; import Form from './utils/formhelper'; window.Hasgeek.Accountform = ({ @@ -37,7 +38,7 @@ window.Hasgeek.Accountform = ({ csrf_token: $('meta[name="csrf-token"]').attr('content'), }), }).catch(() => { - window.toastr.error(window.Hasgeek.Config.errorMsg.networkError); + toastr.error(window.Hasgeek.Config.errorMsg.networkError); }); if (response && response.ok) { const remoteData = await response.json(); @@ -82,7 +83,7 @@ window.Hasgeek.Accountform = ({ csrf_token: $('meta[name="csrf-token"]').attr('content'), }), }).catch(() => { - window.toastr.error(window.Hasgeek.Config.errorMsg.networkError); + toastr.error(window.Hasgeek.Config.errorMsg.networkError); }); if (response && response.ok) { const remoteData = await response.json(); diff --git a/funnel/assets/js/app.js b/funnel/assets/js/app.js index 5ae5181d1..92666baeb 100644 --- a/funnel/assets/js/app.js +++ b/funnel/assets/js/app.js @@ -1,30 +1,28 @@ -/* global jstz, Pace */ - +import 'jquery-modal'; +import 'trunk8'; import Utils from './utils/helper'; +import WebShare from './utils/webshare'; import ScrollHelper from './utils/scrollhelper'; import loadLangTranslations from './utils/translations'; import LazyloadImg from './utils/lazyloadimage'; -import Form from './utils/formhelper'; +import Modal from './utils/modalhelper'; import Analytics from './utils/analytics'; +import Tabs from './utils/tabs'; +import updateParsleyConfig from './utils/update_parsley_config'; +import ReadStatus from './utils/read_status'; +import LazyLoadMenu from './utils/lazyloadmenu'; +import './utils/getDevicePixelRatio'; +import setTimezoneCookie from './utils/timezone'; +import 'muicss/dist/js/mui'; + +const pace = require('pace-js'); $(() => { - window.Hasgeek.Config.availableLanguages = { - en: 'en_IN', - hi: 'hi_IN', - }; - window.Hasgeek.Config.mobileBreakpoint = 768; // this breakpoint switches to desktop UI - window.Hasgeek.Config.ajaxTimeout = 30000; - window.Hasgeek.Config.retryInterval = 10000; - window.Hasgeek.Config.closeModalTimeout = 10000; - window.Hasgeek.Config.refreshInterval = 60000; - window.Hasgeek.Config.notificationRefreshInterval = 300000; - window.Hasgeek.Config.readReceiptTimeout = 5000; - window.Hasgeek.Config.saveEditorContentTimeout = 300; - window.Hasgeek.Config.userAvatarImgSize = { - big: '160', - medium: '80', - small: '48', - }; + /* eslint-disable no-console */ + console.log( + 'Hello, curious geek. Our source is at https://github.com/hasgeek. Why not contribute a patch?' + ); + loadLangTranslations(); window.Hasgeek.Config.errorMsg = { serverError: window.gettext( @@ -40,65 +38,20 @@ $(() => { Utils.collapse(); ScrollHelper.smoothScroll(); Utils.navSearchForm(); - Utils.headerMenuDropdown( - '.js-menu-btn', - '.js-account-menu-wrapper', - '.js-account-menu', - window.Hasgeek.Config.accountMenu - ); ScrollHelper.scrollTabs(); + Tabs.init(); Utils.truncate(); Utils.showTimeOnCalendar(); - Utils.popupBackHandler(); - Form.handleModalForm(); - if ($('.header__nav-links--updates').length) { - Utils.updateNotificationStatus(); - window.setInterval( - Utils.updateNotificationStatus, - window.Hasgeek.Config.notificationRefreshInterval - ); - } - Utils.addWebShare(); - if (window.Hasgeek.Config.commentSidebarElem) { - Utils.headerMenuDropdown( - '.js-comments-btn', - '.js-comments-wrapper', - '.js-comment-sidebar', - window.Hasgeek.Config.unreadCommentUrl - ); - } - Utils.sendNotificationReadStatus(); - - const intersectionObserverComponents = function intersectionObserverComponents() { - LazyloadImg.init('js-lazyload-img'); - }; - - if ( - document.querySelector('.js-lazyload-img') || - document.querySelector('.js-lazyload-results') - ) { - if ( - !( - 'IntersectionObserver' in global && - 'IntersectionObserverEntry' in global && - 'intersectionRatio' in IntersectionObserverEntry.prototype - ) - ) { - const polyfill = document.createElement('script'); - polyfill.setAttribute('type', 'text/javascript'); - polyfill.setAttribute( - 'src', - 'https://cdn.polyfill.io/v2/polyfill.min.js?features=IntersectionObserver' - ); - polyfill.onload = function loadintersectionObserverComponents() { - intersectionObserverComponents(); - }; - document.head.appendChild(polyfill); - } else { - intersectionObserverComponents(); - } - } + Modal.addUsability(); + Analytics.init(); + WebShare.addWebShare(); + ReadStatus.init(); + LazyLoadMenu.init(); + LazyloadImg.init('js-lazyload-img'); + // Request for new CSRF token and update the page every 15 mins + setInterval(Utils.csrfRefresh, 900000); + // Add polyfill if (!('URLSearchParams' in window)) { const polyfill = document.createElement('script'); polyfill.setAttribute('type', 'text/javascript'); @@ -109,21 +62,8 @@ $(() => { document.head.appendChild(polyfill); } - // Send click events to Google analytics - $('.mui-btn, a').click(function gaHandler() { - const action = $(this).attr('data-ga') || $(this).attr('title') || $(this).html(); - const target = $(this).attr('data-target') || $(this).attr('href') || ''; - Analytics.sendToGA('click', action, target); - }); - $('.search-form__submit').click(function gaHandler() { - const target = $('.js-search-field').val(); - Analytics.sendToGA('search', target, target); - }); - - // Detect timezone for login - if ($.cookie('timezone') === null) { - $.cookie('timezone', jstz.determine().name(), { path: '/' }); - } + setTimezoneCookie(); + updateParsleyConfig(); }); if ( @@ -135,6 +75,6 @@ if ( ) { $('.pace').addClass('pace-hide'); window.onbeforeunload = function stopPace() { - Pace.stop(); + pace.stop(); }; } diff --git a/funnel/assets/js/autosave_form.js b/funnel/assets/js/autosave_form.js new file mode 100644 index 000000000..54f0bb1b9 --- /dev/null +++ b/funnel/assets/js/autosave_form.js @@ -0,0 +1,84 @@ +import 'htmx.org'; +import toastr from 'toastr'; +import Form from './utils/formhelper'; + +window.Hasgeek.autoSave = ({ autosave, formId, msgElemId }) => { + let lastSavedData = $(formId).find('[type!="hidden"]').serialize(); + let typingTimer; + const typingWaitInterval = 1000; // wait till user stops typing for one second to send form data + let waitingForResponse = false; + const actionUrl = $(formId).attr('action'); + const sep = actionUrl.indexOf('?') === -1 ? '?' : '&'; + const url = `${actionUrl + sep}`; + + function haveDirtyFields() { + const latestFormData = $(formId).find('[type!="hidden"]').serialize(); + if (latestFormData !== lastSavedData) { + return true; + } + return false; + } + + async function enableAutoSave() { + if (!waitingForResponse && haveDirtyFields()) { + $(msgElemId).text(window.gettext('Saving')); + lastSavedData = $(formId).find('[type!="hidden"]').serialize(); + waitingForResponse = true; + const form = $(formId)[0]; + const response = await fetch(`${url}form.autosave=true`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(new FormData(form)).toString(), + }).catch(() => { + toastr.error(window.Hasgeek.Config.errorMsg.networkError); + }); + if (response && response.ok) { + const remoteData = await response.json(); + if (remoteData) { + // Todo: Update window.history.pushState for new form + $(msgElemId).text(window.gettext('Changes saved but not published')); + if (remoteData.revision) { + $('input[name="form.revision"]').val(remoteData.revision); + } + if (remoteData.form_nonce) { + $('input[name="form_nonce"]').val(remoteData.form_nonce); + } + waitingForResponse = false; + } + } else { + Form.formErrorHandler(formId, response); + } + } + } + + $(window).bind('beforeunload', () => { + if (haveDirtyFields()) { + return window.gettext( + 'You have unsaved changes on this page. Do you want to leave this page?' + ); + } + return true; + }); + + $(formId).on('submit', () => { + $(window).off('beforeunload'); + }); + + if (autosave) { + if ($('input[name="form.revision"]').val()) { + $(msgElemId).text(window.gettext('These changes have not been published yet')); + } + + $(formId).on('change', () => { + enableAutoSave(); + }); + + $(formId).on('keyup', () => { + if (typingTimer) clearTimeout(typingTimer); + typingTimer = setTimeout(enableAutoSave, typingWaitInterval); + }); + } +}; diff --git a/funnel/assets/js/cfp_form.js b/funnel/assets/js/cfp_form.js index 04600a560..7782cce67 100644 --- a/funnel/assets/js/cfp_form.js +++ b/funnel/assets/js/cfp_form.js @@ -1,7 +1,7 @@ -import Form from './utils/formhelper'; +import { Widgets } from './utils/form_widgets'; $(() => { window.Hasgeek.cfpInit = function submissionsInit({ openSubmission = '' }) { - Form.openSubmissionToggle(openSubmission.toggleId, openSubmission.cfpStatusElem); + Widgets.openSubmissionToggle(openSubmission.toggleId, openSubmission.cfpStatusElem); }; }); diff --git a/funnel/assets/js/comments.js b/funnel/assets/js/comments.js index 59ba2c71a..69f58ad99 100644 --- a/funnel/assets/js/comments.js +++ b/funnel/assets/js/comments.js @@ -1,8 +1,15 @@ import Vue from 'vue/dist/vue.min'; +import toastr from 'toastr'; +import { + MOBILE_BREAKPOINT, + SAVE_EDITOR_CONTENT_TIMEOUT, + REFRESH_INTERVAL, +} from './constants'; import ScrollHelper from './utils/scrollhelper'; import Form from './utils/formhelper'; import codemirrorHelper from './utils/codemirror'; -import getTimeago from './utils/getTimeago'; +import getTimeago from './utils/get_timeago'; +import Utils from './utils/helper'; import { userAvatarUI, faSvg, shareDropdown } from './utils/vue_util'; const Comments = { @@ -56,7 +63,7 @@ const Comments = { }; }, methods: { - getInitials: window.Hasgeek.Utils.getInitials, + getInitials: Utils.getInitials, collapse(action) { this.hide = action; }, @@ -172,7 +179,7 @@ const Comments = { }, activateForm(action, textareaId, parentApp = app) { if (textareaId) { - const copyTextAreaContent = function (view) { + const copyTextAreaContent = function copyContentFromCodemirror(view) { if (action === parentApp.COMMENTACTIONS.REPLY) { parentApp.reply = view.state.doc.toString(); } else { @@ -183,7 +190,7 @@ const Comments = { const editorView = codemirrorHelper( textareaId, copyTextAreaContent, - window.Hasgeek.Config.saveEditorContentTimeout + SAVE_EDITOR_CONTENT_TIMEOUT ); editorView.focus(); }); @@ -213,7 +220,7 @@ const Comments = { csrf_token: csrfToken, }).toString(), }).catch(() => { - parentApp.errorMsg = Form.handleFetchNetworkError(); + parentApp.errorMsg = window.Hasgeek.Config.errorMsg.networkError; }); if (response && response.ok) { const responseData = await response.json(); @@ -228,7 +235,7 @@ const Comments = { } if (responseData.comments) { app.updateCommentsList(responseData.comments); - window.toastr.success(responseData.message); + toastr.success(responseData.message); } if (responseData.comment) { app.scrollTo = `#c-${responseData.comment.uuid_b58}`; @@ -261,10 +268,10 @@ const Comments = { refreshCommentsTimer() { this.refreshTimer = window.setInterval( this.fetchCommentsList, - window.Hasgeek.Config.refreshInterval + REFRESH_INTERVAL ); }, - getInitials: window.Hasgeek.Utils.getInitials, + getInitials: Utils.getInitials, }, mounted() { this.fetchCommentsList(); @@ -326,7 +333,7 @@ const Comments = { this.initialLoad = false; } if (this.scrollTo) { - if ($(window).width() < window.Hasgeek.Config.mobileBreakpoint) { + if ($(window).width() < MOBILE_BREAKPOINT) { ScrollHelper.animateScrollTo( $(this.scrollTo).offset().top - this.headerHeight ); diff --git a/funnel/assets/js/constants/index.js b/funnel/assets/js/constants/index.js new file mode 100644 index 000000000..a1cf6b8e1 --- /dev/null +++ b/funnel/assets/js/constants/index.js @@ -0,0 +1,15 @@ +export const MOBILE_BREAKPOINT = 768; // this breakpoint switches to desktop UI +export const AJAX_TIMEOUT = 30000; +export const RETRY_INTERVAL = 10000; +export const CLOSE_MODAL_TIMEOUT = 10000; +export const REFRESH_INTERVAL = 60000; +export const NOTIFICATION_REFRESH_INTERVAL = 300000; +export const READ_RECEIPT_TIMEOUT = 5000; +export const SAVE_EDITOR_CONTENT_TIMEOUT = 300; +export const USER_AVATAR_IMG_SIZE = { + big: '160', + medium: '80', + small: '48', +}; +export const DEFAULT_LATITUDE = '12.961443'; +export const DEFAULT_LONGITUDE = '77.64435000000003'; diff --git a/funnel/assets/js/form.js b/funnel/assets/js/form.js index 89ffa0301..b4d896ccd 100644 --- a/funnel/assets/js/form.js +++ b/funnel/assets/js/form.js @@ -1,89 +1,118 @@ -import 'htmx.org'; +/* global grecaptcha */ +import { activateFormWidgets, MapMarker } from './utils/form_widgets'; import Form from './utils/formhelper'; -import codemirrorHelper from './utils/codemirror'; - -window.Hasgeek.form = ({ autosave, formId, msgElemId }) => { - let lastSavedData = $(formId).find('[type!="hidden"]').serialize(); - let typingTimer; - const typingWaitInterval = 1000; // wait till user stops typing for one second to send form data - let waitingForResponse = false; - const actionUrl = $(formId).attr('action'); - const sep = actionUrl.indexOf('?') === -1 ? '?' : '&'; - const url = `${actionUrl + sep}`; +import 'htmx.org'; - function haveDirtyFields() { - const latestFormData = $('form').find('[type!="hidden"]').serialize(); - if (latestFormData !== lastSavedData) { - return true; +window.Hasgeek.initWidgets = async function init(fieldName, config) { + switch (fieldName) { + case 'AutocompleteField': { + const { default: widget } = await import('./utils/autocomplete_widget'); + widget.textAutocomplete(config); + break; } - return false; - } - - async function enableAutoSave() { - if (!waitingForResponse && haveDirtyFields()) { - $(msgElemId).text(window.gettext('Saving')); - lastSavedData = $(formId).find('[type!="hidden"]').serialize(); - waitingForResponse = true; - const form = $(formId)[0]; - const response = await fetch(`${url}form.autosave=true`, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams(new FormData(form)).toString(), - }).catch(() => { - Form.handleFetchNetworkError(); - }); - if (response && response.ok) { - const remoteData = await response.json(); - if (remoteData) { - // Todo: Update window.history.pushState for new form - $(msgElemId).text(window.gettext('Changes saved but not published')); - if (remoteData.revision) { - $('input[name="form.revision"]').val(remoteData.revision); - } - if (remoteData.form_nonce) { - $('input[name="form_nonce"]').val(remoteData.form_nonce); + case 'ImgeeField': + window.addEventListener('message', (event) => { + if (event.origin === config.host) { + const message = JSON.parse(event.data); + if (message.context === 'imgee.upload') { + $(`#imgee-loader-${config.fieldId}`).removeClass('mui--hide'); + $(`#img_${config.fieldId}`).attr('src', message.embed_url); + $(`#${config.fieldId}`).val(message.embed_url); + if (config.widgetType) $.modal.close(); } - waitingForResponse = false; } - } else { - Form.formErrorHandler(formId, response); - } - } - } - - $(window).bind('beforeunload', () => { - if (haveDirtyFields()) { - return window.gettext( - 'You have unsaved changes on this page. Do you want to leave this page?' - ); + }); + $(`#img_${config.fieldId}`).on('load', () => { + $(`#imgee-loader-${config.fieldId}`).addClass('mui--hide'); + }); + break; + case 'UserSelectField': { + const { default: lastUserWidget } = await import('./utils/autocomplete_widget'); + lastUserWidget.lastuserAutocomplete(config); + break; } - return true; - }); - - $(formId).on('submit', () => { - $(window).off('beforeunload'); - }); - - if (autosave) { - if ($('input[name="form.revision"]').val()) { - $(msgElemId).text(window.gettext('These changes have not been published yet')); + case 'GeonameSelectField': { + const { default: geonameWidget } = await import('./utils/autocomplete_widget'); + geonameWidget.geonameAutocomplete(config); + break; } + case 'CoordinatesField': + /* eslint-disable no-new */ + await import('jquery-locationpicker'); + new MapMarker(config); + break; + default: + break; + } +}; - $(formId).on('change', () => { - enableAutoSave(); +window.Hasgeek.preventDoubleSubmit = function stopDoubleSubmit( + formId, + isXHR, + alertBoxHtml +) { + if (isXHR) { + document.body.addEventListener('htmx:beforeSend', () => { + Form.preventDoubleSubmit(formId); }); - - $(formId).on('keyup', () => { - if (typingTimer) clearTimeout(typingTimer); - typingTimer = setTimeout(enableAutoSave, typingWaitInterval); + document.body.addEventListener('htmx:responseError', (event) => { + Form.showFormError(formId, event.detail.xhr, alertBoxHtml); + }); + } else { + $(() => { + // Disable submit button when clicked. Prevent double click. + $(`#${formId}`).submit(function onSubmit() { + if ( + !$(this).data('parsley-validate') || + ($(this).data('parsley-validate') && $(this).hasClass('parsley-valid')) + ) { + $(this).find('button[type="submit"]').prop('disabled', true); + $(this).find('input[type="submit"]').prop('disabled', true); + $(this).find('.loading').removeClass('mui--hide'); + } + }); }); } +}; - $('textarea.markdown:not([style*="display: none"]').each(function enableCodemirror() { - const markdownId = $(this).attr('id'); - codemirrorHelper(markdownId); - }); +window.Hasgeek.recaptcha = function handleRecaptcha( + formId, + formWrapperId, + ajax, + alertBoxHtml +) { + if (ajax) { + window.onInvisibleRecaptchaSubmit = function handleAjaxFormSubmit() { + const postUrl = $(`#${formId}`).attr('action'); + const onSuccess = function onSubmitSuccess(responseData) { + $(`#${formWrapperId}`).html(responseData); // Replace with OTP form received as response + }; + const onError = function onSubmitError(response) { + Form.showFormError(formId, response, alertBoxHtml); + }; + Form.ajaxFormSubmit(formId, postUrl, onSuccess, onError, { + dataType: 'html', + }); + }; + document.getElementById(formId).onsubmit = function onSubmit(event) { + event.preventDefault(); + grecaptcha.execute(); + }; + } else { + window.onInvisibleRecaptchaSubmit = function recaptchaSubmit() { + document.getElementById(formId).submit(); + }; + document.getElementById(formId).onsubmit = function handleFormSubmit(event) { + event.preventDefault(); + if (typeof grecaptcha !== 'undefined' && grecaptcha.getResponse() === '') { + grecaptcha.execute(); + } else { + document.getElementById(formId).submit(); + } + }; + } }; + +$(() => { + activateFormWidgets(); +}); diff --git a/funnel/assets/js/index.js b/funnel/assets/js/index.js index 93429c0d8..d3eaeb86a 100644 --- a/funnel/assets/js/index.js +++ b/funnel/assets/js/index.js @@ -1,9 +1,10 @@ import SaveProject from './utils/bookmark'; import 'htmx.org'; import initEmbed from './utils/initembed'; +import Ticketing from './utils/ticket_widget'; $(() => { - window.Hasgeek.homeInit = function homeInit(markdownContainer) { + window.Hasgeek.homeInit = function homeInit(markdownContainer, tickets = '') { // Expand CFP section $('.jquery-show-all').click(function showAll(event) { event.preventDefault(); @@ -20,5 +21,9 @@ $(() => { SaveProject(projectSaveConfig); }); initEmbed(markdownContainer); + + if (tickets) { + Ticketing.init(tickets); + } }; }); diff --git a/funnel/assets/js/labels.js b/funnel/assets/js/labels.js index 50f16a8af..0e0fe1bec 100644 --- a/funnel/assets/js/labels.js +++ b/funnel/assets/js/labels.js @@ -1,7 +1,7 @@ import 'jquery-ui'; import 'jquery-ui-sortable-npm'; import 'jquery-ui-touch-punch'; -import Form from './utils/formhelper'; +import { Widgets } from './utils/form_widgets'; $(() => { function applySortable() { @@ -27,5 +27,5 @@ $(() => { const onSuccessFn = () => { window.location.reload(); }; - Form.handleDelete('.js-delete-btn', onSuccessFn); + Widgets.handleDelete('.js-delete-btn', onSuccessFn); }); diff --git a/funnel/assets/js/labels_form.js b/funnel/assets/js/labels_form.js index a2906946e..1c066bdb0 100644 --- a/funnel/assets/js/labels_form.js +++ b/funnel/assets/js/labels_form.js @@ -2,6 +2,8 @@ import 'jquery-ui'; import 'jquery-ui-sortable-npm'; import 'jquery-ui-touch-punch'; import 'emojionearea'; +import toastr from 'toastr'; +import { activateFormWidgets } from './utils/form_widgets'; $(() => { window.Hasgeek.LabelsFormInit = function LabelsFormInit(formHtml) { @@ -30,7 +32,7 @@ $(() => { $('#add-sublabel-form').click((e) => { e.preventDefault(); $('#child-form').append(formHtml); - window.activate_widgets(); + activateFormWidgets(); initEmojiPicker(); $('.js-required-field').removeClass('mui--hide'); $('.js-required-field input').prop('checked', true); @@ -47,7 +49,7 @@ $(() => { const optionCount = $('#child-form').find('.ui-draggable-box').length; if (optionCount === 1) { e.preventDefault(); - window.toastr.error('Minimum 2 or more options are needed'); + toastr.error('Minimum 2 or more options are needed'); return false; } return true; diff --git a/funnel/assets/js/membership.js b/funnel/assets/js/membership.js index 6a446e420..2f1ca38f5 100644 --- a/funnel/assets/js/membership.js +++ b/funnel/assets/js/membership.js @@ -1,6 +1,9 @@ import Vue from 'vue/dist/vue.min'; import VS2 from 'vue-script2'; +import toastr from 'toastr'; +import { MOBILE_BREAKPOINT } from './constants'; import Form from './utils/formhelper'; +import Utils from './utils/helper'; import { userAvatarUI, faSvg } from './utils/vue_util'; const Membership = { @@ -14,19 +17,19 @@ const Membership = { }) { Vue.use(VS2); - const memberUI = Vue.component('member', { + const memberUI = Vue.component('membership', { template: memberTemplate, - props: ['member'], + props: ['membership'], methods: { - rolesCount(member) { + rolesCount(membership) { let count = 0; - if (member.is_editor) count += 1; - if (member.is_promoter) count += 1; - if (member.is_usher) count += 1; + if (membership.is_editor) count += 1; + if (membership.is_promoter) count += 1; + if (membership.is_usher) count += 1; return count - 1; }, - getInitials: window.Hasgeek.Utils.getInitials, - getAvatarColour: window.Hasgeek.Utils.getAvatarColour, + getInitials: Utils.getInitials, + getAvatarColour: Utils.getAvatarColour, }, }); @@ -65,12 +68,15 @@ const Membership = { headers: { Accept: 'application/json', }, - }).catch(Form.handleFetchNetworkError); + }).catch(() => { + toastr.error(window.Hasgeek.Config.errorMsg.networkError); + }); if (response && response.ok) { const data = await response.json(); if (data) { const vueFormHtml = data.form; app.memberForm = vueFormHtml.replace(/\bscript\b/g, 'script2'); + app.errorMsg = ''; $('#member-form').modal('show'); } } else { @@ -86,13 +92,13 @@ const Membership = { if (responseData.memberships) { this.updateMembersList(responseData.memberships); this.onChange(); - window.toastr.success(responseData.message); + toastr.success(responseData.message); } }; const onError = (response) => { this.errorMsg = Form.formErrorHandler(formId, response); }; - window.Hasgeek.Forms.handleFormSubmit(formId, url, onSuccess, onError, {}); + Form.handleFormSubmit(formId, url, onSuccess, onError, {}); }, updateMembersList(membersList) { this.members = membersList.length > 0 ? membersList : ''; @@ -108,9 +114,11 @@ const Membership = { }, onChange() { if (this.search) { - this.members.filter((member) => { - member.hide = - member.user.fullname + this.members.filter((membership) => { + /* FIXME: This is using fullname to identify a member, + it should use an id */ + membership.hide = + membership.member.fullname .toLowerCase() .indexOf(this.search.toLowerCase()) === -1; return true; @@ -126,7 +134,7 @@ const Membership = { this.showInfo = !this.showInfo; }, onWindowResize() { - this.isMobile = $(window).width() < window.Hasgeek.Config.mobileBreakpoint; + this.isMobile = $(window).width() < MOBILE_BREAKPOINT; }, }, computed: { diff --git a/funnel/assets/js/notification_list.js b/funnel/assets/js/notification_list.js index 6120706d1..4b6eb277e 100644 --- a/funnel/assets/js/notification_list.js +++ b/funnel/assets/js/notification_list.js @@ -1,5 +1,6 @@ import Vue from 'vue/dist/vue.min'; import Utils from './utils/helper'; +import { READ_RECEIPT_TIMEOUT, REFRESH_INTERVAL } from './constants'; const Notification = { init({ markReadUrl, divElem }) { @@ -116,7 +117,7 @@ const Notification = { $(entry.target).attr('data-visible-time', entry.time); window.setTimeout(() => { app.updateReadStatus(entry.target); - }, window.Hasgeek.Config.readReceiptTimeout); + }, READ_RECEIPT_TIMEOUT); } else { $(entry.target).attr('data-visible-time', ''); } @@ -128,7 +129,7 @@ const Notification = { this.lazyoad(); window.setInterval(() => { this.fetchResult(1, true); - }, window.Hasgeek.Config.refreshInterval); + }, REFRESH_INTERVAL); }, updated() { const app = this; diff --git a/funnel/assets/js/notification_settings.js b/funnel/assets/js/notification_settings.js index d27facb54..5f6ac0347 100644 --- a/funnel/assets/js/notification_settings.js +++ b/funnel/assets/js/notification_settings.js @@ -1,10 +1,35 @@ +import toastr from 'toastr'; import Form from './utils/formhelper'; +import ScrollHelper from './utils/scrollhelper'; $(() => { window.Hasgeek.notificationSettings = (config) => { + let [tab] = config.tabs; + const headerHeight = + ScrollHelper.getPageHeaderHeight() + $('.tabs-wrapper').height(); + if (window.location.hash) { + const urlHash = window.location.hash.split('#').pop(); + config.tabs.forEach((tabVal) => { + if (urlHash.includes(tabVal)) { + tab = tabVal; + } + }); + } else { + window.location.hash = tab; + } + ScrollHelper.animateScrollTo($(`#${tab}`).offset().top - headerHeight); + $(`.js-pills-tab-${tab}`).addClass('mui--is-active'); + $(`.js-pills-tab-${tab}`).find('a').attr('tabindex', 1).attr('aria-selected', true); + $(`.js-tabs-pane-${tab}`).addClass('mui--is-active'); + + $('.js-tab-anchor').on('click', function scrollToTabpane() { + const tabPane = $('.js-tab-anchor').attr('href'); + ScrollHelper.animateScrollTo($(tabPane).offset().top - headerHeight); + }); + $('.js-toggle-switch').on('change', function toggleNotifications() { const checkbox = $(this); - const transport = $(this).attr('id'); + const transport = $(this).attr('data-transport'); const currentState = this.checked; const previousState = !currentState; const form = $(this).parents('.js-autosubmit-form')[0]; @@ -33,7 +58,8 @@ $(() => { } }) .catch((error) => { - Form.handleAjaxError(error); + const errorMsg = Form.handleAjaxError(error); + toastr.error(errorMsg); $(checkbox).prop('checked', previousState); }); }); diff --git a/funnel/assets/js/project_header.js b/funnel/assets/js/project_header.js index 8e5941a87..e352f3470 100644 --- a/funnel/assets/js/project_header.js +++ b/funnel/assets/js/project_header.js @@ -1,170 +1,19 @@ import SaveProject from './utils/bookmark'; import Video from './utils/embedvideo'; -import Analytics from './utils/analytics'; import Spa from './utils/spahelper'; -import Form from './utils/formhelper'; +import { Widgets } from './utils/form_widgets'; import initEmbed from './utils/initembed'; - -const Ticketing = { - init(tickets) { - if (tickets.boxofficeUrl) { - this.initBoxfficeWidget(tickets); - } - - this.initTicketModal(); - }, - - initBoxfficeWidget({ - boxofficeUrl, - widgetElem, - org, - itemCollectionId, - itemCollectionTitle, - }) { - let url; - - if (boxofficeUrl.slice(-1) === '/') { - url = `${boxofficeUrl}boxoffice.js`; - } else { - url = `${boxofficeUrl}/boxoffice.js`; - } - - $.get({ - url, - crossDomain: true, - timeout: window.Hasgeek.Config.ajaxTimeout, - retries: 5, - retryInterval: window.Hasgeek.Config.retryInterval, - - success(data) { - const boxofficeScript = document.createElement('script'); - boxofficeScript.innerHTML = data.script; - document.getElementsByTagName('body')[0].appendChild(boxofficeScript); - }, - - error(response) { - const ajaxLoad = this; - ajaxLoad.retries -= 1; - let errorMsg; - - if (response.readyState === 4) { - errorMsg = window.gettext( - 'The server is experiencing difficulties. Try again in a few minutes' - ); - $(widgetElem).html(errorMsg); - } else if (response.readyState === 0) { - if (ajaxLoad.retries < 0) { - if (!navigator.onLine) { - errorMsg = window.gettext('This device has no internet connection'); - } else { - errorMsg = window.gettext( - 'Unable to connect. If this device is behind a firewall or using any script blocking extension (like Privacy Badger), please ensure your browser can load boxoffice.hasgeek.com, api.razorpay.com and checkout.razorpay.com' - ); - } - - $(widgetElem).html(errorMsg); - } else { - setTimeout(() => { - $.get(ajaxLoad); - }, ajaxLoad.retryInterval); - } - } - }, - }); - window.addEventListener( - 'onBoxofficeInit', - () => { - window.Boxoffice.init({ - org, - itemCollection: itemCollectionId, - paymentDesc: itemCollectionTitle, - }); - }, - false - ); - $(document).on('boxofficeTicketingEvents', (event, userAction, label, value) => { - Analytics.sendToGA('ticketing', userAction, label, value); - }); - $(document).on( - 'boxofficeShowPriceEvent', - (event, prices, currency, quantityAvailable) => { - let price; - let maxPrice; - const isTicketAvailable = - quantityAvailable.length > 0 - ? Math.min.apply(null, quantityAvailable.filter(Boolean)) - : 0; - const minPrice = prices.length > 0 ? Math.min(...prices) : -1; - if (!isTicketAvailable || minPrice < 0) { - $('.js-tickets-available').addClass('mui--hide'); - $('.js-tickets-not-available').removeClass('mui--hide'); - $('.js-open-ticket-widget') - .addClass('mui--is-disabled') - .prop('disabled', true); - } else { - price = `${currency}${minPrice}`; - if (prices.length > 1) { - maxPrice = Math.max(...prices); - price = `${currency}${minPrice} - ${currency}${maxPrice}`; - } - $('.js-ticket-price').text(price); - } - } - ); - }, - - initTicketModal() { - this.urlHash = '#tickets'; - if (window.location.hash.indexOf(this.urlHash) > -1) { - this.openTicketModal(); - } - - $('.js-open-ticket-widget').click((event) => { - event.preventDefault(); - this.openTicketModal(); - }); - - $('body').on('click', '#close-ticket-widget', (event) => { - event.preventDefault(); - this.hideTicketModal(); - }); - - $(window).on('popstate', () => { - this.hideTicketModal(); - }); - }, - - openTicketModal() { - window.history.pushState( - { - openModal: true, - }, - '', - this.urlHash - ); - $('.header').addClass('header--lowzindex'); - $('.tickets-wrapper__modal').addClass('tickets-wrapper__modal--show'); - $('.tickets-wrapper__modal').show(); - }, - - hideTicketModal() { - if ($('.tickets-wrapper__modal').hasClass('tickets-wrapper__modal--show')) { - $('.header').removeClass('header--lowzindex'); - $('.tickets-wrapper__modal').removeClass('tickets-wrapper__modal--show'); - $('.tickets-wrapper__modal').hide(); - if (window.history.state.openModal) { - window.history.back(); - } - } - }, -}; +import SortItem from './utils/sort'; +import Ticketing from './utils/ticket_widget'; $(() => { window.Hasgeek.projectHeaderInit = ( projectTitle, saveProjectConfig = '', tickets = '', - toggleId = '' + toggleId = '', + sort = '', + rsvpModalHash = 'register-modal' ) => { if (saveProjectConfig) { SaveProject(saveProjectConfig); @@ -186,10 +35,16 @@ $(() => { } $('a.js-register-btn').click(function showRegistrationModal() { - $(this).modal('show'); + window.history.pushState( + { + openModal: true, + }, + '', + `#${rsvpModalHash}` + ); }); - if (window.location.hash.includes('register-modal')) { + if (window.location.hash.includes(rsvpModalHash)) { $('a.js-register-btn').modal('show'); } @@ -198,7 +53,11 @@ $(() => { } if (toggleId) { - Form.activateToggleSwitch(toggleId); + Widgets.activateToggleSwitch(toggleId); + } + + if (sort?.url) { + SortItem($(sort.wrapperElem), sort.placeholder, sort.url); } const hightlightNavItem = (navElem) => { diff --git a/funnel/assets/js/rsvp_form_modal.js b/funnel/assets/js/rsvp_form_modal.js new file mode 100644 index 000000000..96cd64e9c --- /dev/null +++ b/funnel/assets/js/rsvp_form_modal.js @@ -0,0 +1,33 @@ +import Vue from 'vue/dist/vue.esm'; +import jsonForm from './utils/jsonform'; + +Vue.config.devtools = true; + +const FormUI = { + init(jsonSchema) { + /* eslint-disable no-new */ + new Vue({ + el: '#register-form', + data() { + return { + jsonSchema, + }; + }, + components: { + jsonForm, + }, + methods: { + handleAjaxPost() { + window.location.hash = ''; + window.location.reload(); + }, + }, + }); + }, +}; + +$(() => { + window.Hasgeek.addRsvpForm = (jsonSchema) => { + FormUI.init(jsonSchema); + }; +}); diff --git a/funnel/assets/js/scan_badge.js b/funnel/assets/js/scan_badge.js index d43301dd8..13c0706ac 100644 --- a/funnel/assets/js/scan_badge.js +++ b/funnel/assets/js/scan_badge.js @@ -1,5 +1,7 @@ import jsQR from 'jsqr'; +import toastr from 'toastr'; import { RactiveApp } from './utils/ractive_util'; +import { CLOSE_MODAL_TIMEOUT } from './constants'; const badgeScan = { init({ checkinApiUrl, wrapperId, templateId, projectTitle, ticketEventTitle }) { @@ -40,7 +42,7 @@ const badgeScan = { const closeModal = () => { window.setTimeout(() => { badgeScanComponent.closeModal(); - }, window.Hasgeek.Config.closeModalTimeout); + }, CLOSE_MODAL_TIMEOUT); }; const handleError = () => { @@ -60,7 +62,9 @@ const badgeScan = { body: new URLSearchParams({ csrf_token: csrfToken, }).toString(), - }).catch(window.toastr.error(window.Hasgeek.Config.errorMsg.networkError)); + }).catch(() => { + toastr.error(window.Hasgeek.Config.errorMsg.networkError); + }); if (response && response.ok) { const responseData = await response.json(); if (responseData) { diff --git a/funnel/assets/js/scan_contact.js b/funnel/assets/js/scan_contact.js index 8ac5cae7a..c782589ff 100644 --- a/funnel/assets/js/scan_contact.js +++ b/funnel/assets/js/scan_contact.js @@ -1,5 +1,6 @@ import jsQR from 'jsqr'; import vCardsJS from 'vcards-js'; +import toastr from 'toastr'; import Form from './utils/formhelper'; import { RactiveApp } from './utils/ractive_util'; @@ -69,7 +70,9 @@ const badgeScan = { 'Content-Type': 'application/x-www-form-urlencoded', }, body: formValues, - }).catch(Form.handleFetchNetworkError); + }).catch(() => { + toastr.error(window.Hasgeek.Config.errorMsg.networkError); + }); if (response && response.ok) { const responseData = await response.json(); if (responseData) { diff --git a/funnel/assets/js/schedule_view.js b/funnel/assets/js/schedule_view.js index 46ebf3c41..63875eecd 100644 --- a/funnel/assets/js/schedule_view.js +++ b/funnel/assets/js/schedule_view.js @@ -1,10 +1,13 @@ import Vue from 'vue/dist/vue.min'; +import toastr from 'toastr'; +import { MOBILE_BREAKPOINT } from './constants'; import ScrollHelper from './utils/scrollhelper'; import { faSvg } from './utils/vue_util'; import Form from './utils/formhelper'; import Spa from './utils/spahelper'; -import Utils from './utils/helper'; +import WebShare from './utils/webshare'; import initEmbed from './utils/initembed'; +import Modal from './utils/modalhelper'; const Schedule = { renderScheduleTable() { @@ -38,7 +41,7 @@ const Schedule = { }, methods: { toggleTab(room) { - if (this.width < window.Hasgeek.Config.mobileBreakpoint) { + if (this.width < MOBILE_BREAKPOINT) { this.activeTab = room; } }, @@ -55,10 +58,7 @@ const Schedule = { return new Date(parseInt(time, 10)).toLocaleTimeString('en-GB', options); }, getColumnWidth(columnType) { - if ( - columnType === 'header' || - this.width >= window.Hasgeek.Config.mobileBreakpoint - ) { + if (columnType === 'header' || this.width >= MOBILE_BREAKPOINT) { if (this.view === 'calendar') { return this.timeSlotWidth / this.rowWidth; } @@ -84,33 +84,39 @@ const Schedule = { // On closing modal, update browser history $('#session-modal').on($.modal.CLOSE, () => { this.modalHtml = ''; - Spa.updateMetaTags(this.pageDetails); - if (window.history.state.openModal) { - window.history.back(); - } - }); - $(window).on('popstate', () => { - if (this.modalHtml) { - $.modal.close(); + if (schedule.config.replaceHistoryToModalUrl) { + Spa.updateMetaTags(this.pageDetails); + if (window.history.state.openModal) { + window.history.back(); + } } }); + if (schedule.config.changeToModalUrl) { + $(window).on('popstate', () => { + if (this.modalHtml) { + $.modal.close(); + } + }); + } }, openModal(sessionHtml, currentPage, pageDetails) { this.modalHtml = sessionHtml; $('#session-modal').modal('show'); this.handleModalShown(); - window.history.pushState( - { - openModal: true, - }, - '', - currentPage - ); - Spa.updateMetaTags(pageDetails); + if (schedule.config.replaceHistoryToModalUrl) { + window.history.pushState( + { + openModal: true, + }, + '', + currentPage + ); + Spa.updateMetaTags(pageDetails); + } }, handleFetchError(error) { const errorMsg = Form.getFetchError(error); - window.toastr.error(errorMsg); + toastr.error(errorMsg); }, async showSessionModal(activeSession) { const currentPage = `${this.pageDetails.url}/${activeSession.url_name_uuid_b58}`; @@ -128,7 +134,9 @@ const Schedule = { Accept: 'text/x.fragment+html', 'X-Requested-With': 'XMLHttpRequest', }, - }).catch(Form.handleFetchNetworkError); + }).catch(() => { + toastr.error(window.Hasgeek.Config.errorMsg.networkError); + }); if (response && response.ok) { const responseData = await response.text(); this.openModal(responseData, currentPage, pageDetails); @@ -143,8 +151,8 @@ const Schedule = { const callback = (mutationList, observer) => { mutationList.forEach((mutation) => { if (mutation.type === 'childList') { - window.activateZoomPopup(); - Utils.enableWebShare(); + Modal.activateZoomPopup(); + WebShare.enableWebShare(); initEmbed(`#session-modal .markdown`); observer.disconnect(); } @@ -166,7 +174,7 @@ const Schedule = { this.width = $(window).width(); this.height = $(window).height(); - if (this.width < window.Hasgeek.Config.mobileBreakpoint) { + if (this.width < MOBILE_BREAKPOINT) { this.view = 'agenda'; } this.getHeight(); @@ -235,7 +243,9 @@ const Schedule = { }, }, mounted() { - this.animateWindowScrollWithHeader(); + if (schedule.config.rememberScrollPos) { + this.animateWindowScrollWithHeader(); + } this.handleBrowserResize(); this.handleBrowserHistory(); }, diff --git a/funnel/assets/js/submission.js b/funnel/assets/js/submission.js index abca81e9f..5782f54e6 100644 --- a/funnel/assets/js/submission.js +++ b/funnel/assets/js/submission.js @@ -1,11 +1,13 @@ +import toastr from 'toastr'; import Form from './utils/formhelper'; -import Utils from './utils/helper'; +import { Widgets } from './utils/form_widgets'; +import WebShare from './utils/webshare'; import initEmbed from './utils/initembed'; export const Submission = { init(toggleId) { - if (toggleId) Form.activateToggleSwitch(toggleId); - Utils.enableWebShare(); + if (toggleId) Widgets.activateToggleSwitch(toggleId); + WebShare.enableWebShare(); $('.js-subscribe-btn').on('click', function subscribeComments(event) { event.preventDefault(); const form = $(this).parents('form')[0]; @@ -21,12 +23,14 @@ export const Submission = { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams(new FormData(form)).toString(), - }).catch(Form.handleFetchNetworkError); + }).catch(() => { + toastr.error(window.Hasgeek.Config.errorMsg.networkError); + }); if (response && response.ok) { const responseData = await response.json(); if (responseData) { if (responseData.message) { - window.toastr.success(responseData.message); + toastr.success(responseData.message); } $('.js-subscribed, .js-unsubscribed').toggleClass('mui--hide'); Form.updateFormNonce(responseData); diff --git a/funnel/assets/js/submission_form.js b/funnel/assets/js/submission_form.js index abdbfea25..c24996a41 100644 --- a/funnel/assets/js/submission_form.js +++ b/funnel/assets/js/submission_form.js @@ -1,23 +1,26 @@ +import toastr from 'toastr'; import codemirrorHelper from './utils/codemirror'; import initEmbed from './utils/initembed'; import Form from './utils/formhelper'; +import { Widgets } from './utils/form_widgets'; import SortItem from './utils/sort'; $(() => { window.Hasgeek.submissionFormInit = function formInit( sortUrl, formId, - markdownPreviewElem + markdownPreviewElem, + markdownPreviewApi ) { function updateCollaboratorsList(responseData, updateModal = true) { if (updateModal) $.modal.close(); - if (responseData.message) window.toastr.success(responseData.message); + if (responseData.message) toastr.success(responseData.message); if (responseData.html) $('.js-collaborator-list').html(responseData.html); if (updateModal) $('.js-add-collaborator').trigger('click'); } async function updatePreview(view) { - const response = await fetch(window.Hasgeek.Config.markdownPreviewApi, { + const response = await fetch(markdownPreviewApi, { method: 'POST', headers: { Accept: 'application/json', @@ -76,7 +79,6 @@ $(() => { $('body').on($.modal.OPEN, '.modal', (event) => { event.preventDefault(); - $('select.select2').select2('open').trigger('select2:open'); const modalFormId = $('.modal').find('form').attr('id'); const url = Form.getActionUrl(modalFormId); const onSuccess = (responseData) => { @@ -85,7 +87,7 @@ $(() => { const onError = (response) => { Form.formErrorHandler(modalFormId, response); }; - window.Hasgeek.Forms.handleFormSubmit(modalFormId, url, onSuccess, onError, {}); + Form.handleFormSubmit(modalFormId, url, onSuccess, onError, {}); }); $('.js-switch-panel').on('click', (event) => { @@ -124,10 +126,8 @@ $(() => { }); const markdownId = $(`#${formId}`).find('textarea.markdown').attr('id'); - if ($(`#${markdownId}`).next().hasClass('cm-editor')) { - $(`#${markdownId}`).next().remove(); - } codemirrorHelper(markdownId, updatePreview); + initEmbed(markdownPreviewElem); $('#title') .keypress((event) => { @@ -140,7 +140,7 @@ $(() => { ); }); - Form.handleDelete('.js-remove-collaborator', updateCollaboratorsList); + Widgets.handleDelete('.js-remove-collaborator', updateCollaboratorsList); SortItem($('.js-collaborator-list'), 'collaborator-placeholder', sortUrl); }; diff --git a/funnel/assets/js/submissions.js b/funnel/assets/js/submissions.js index a055c57b4..269426645 100644 --- a/funnel/assets/js/submissions.js +++ b/funnel/assets/js/submissions.js @@ -1,6 +1,6 @@ import TableSearch from './utils/tablesearch'; import SortItem from './utils/sort'; -import Form from './utils/formhelper'; +import { Widgets } from './utils/form_widgets'; $(() => { window.Hasgeek.submissionsInit = function submissionsInit({ @@ -24,11 +24,14 @@ $(() => { } if (sort.permission) { - SortItem($('.proposal-list-table tbody'), 'proposal-placeholder', sort.url); + SortItem($(sort.wrapperElem), sort.placeholder, sort.url); } if (openSubmission) { - Form.openSubmissionToggle(openSubmission.toggleId, openSubmission.cfpStatusElem); + Widgets.openSubmissionToggle( + openSubmission.toggleId, + openSubmission.cfpStatusElem + ); } }; }); diff --git a/funnel/assets/js/update.js b/funnel/assets/js/update.js index 965d101c6..b69b027f4 100644 --- a/funnel/assets/js/update.js +++ b/funnel/assets/js/update.js @@ -2,7 +2,7 @@ import Vue from 'vue/dist/vue.min'; import VS2 from 'vue-script2'; import Utils from './utils/helper'; import ScrollHelper from './utils/scrollhelper'; -import getTimeago from './utils/getTimeago'; +import getTimeago from './utils/get_timeago'; import { userAvatarUI, faSvg, shareDropdown } from './utils/vue_util'; const Updates = { @@ -21,7 +21,7 @@ const Updates = { }; }, methods: { - getInitials: window.Hasgeek.Utils.getInitials, + getInitials: Utils.getInitials, truncate(content, length) { if (!content) return ''; const value = content.toString(); diff --git a/funnel/assets/js/utils/analytics.js b/funnel/assets/js/utils/analytics.js index b701c6fb3..d7e751064 100644 --- a/funnel/assets/js/utils/analytics.js +++ b/funnel/assets/js/utils/analytics.js @@ -1,16 +1,30 @@ -/* global ga */ +/* global gtag */ const Analytics = { - sendToGA(category, action, label, value = 0) { + sendToGA(category, action, label = '', value = 0) { if (typeof ga !== 'undefined') { - ga('send', { - hitType: 'event', - eventCategory: category, - eventAction: action, - eventLabel: label, - eventValue: value, + gtag('event', category, { + event_category: action, + event_label: label, + value, }); } }, + init() { + // Send click events to Google analytics + $('.mui-btn, a').click(function gaHandler() { + const action = $(this).attr('data-ga') || $(this).attr('title') || $(this).html(); + const target = $(this).attr('data-target') || $(this).attr('href') || ''; + Analytics.sendToGA('click', action, target); + }); + $('.ga-login-btn').click(function gaHandler() { + const action = $(this).attr('data-ga'); + Analytics.sendToGA('login', action); + }); + $('.search-form__submit').click(function gaHandler() { + const target = $('.js-search-field').val(); + Analytics.sendToGA('search', target, target); + }); + }, }; export default Analytics; diff --git a/funnel/assets/js/utils/autocomplete_widget.js b/funnel/assets/js/utils/autocomplete_widget.js new file mode 100644 index 000000000..00793d902 --- /dev/null +++ b/funnel/assets/js/utils/autocomplete_widget.js @@ -0,0 +1,120 @@ +import 'select2'; + +const EnableAutocompleteWidgets = { + lastuserAutocomplete(options) { + const assembleUsers = function getUsersMap(users) { + return users.map((user) => { + return { id: user.buid, text: user.label }; + }); + }; + + $(`#${options.id}`).select2({ + placeholder: 'Search for a user', + multiple: options.multiple, + minimumInputLength: 2, + ajax: { + url: options.autocompleteEndpoint, + dataType: 'jsonp', + data(params) { + if ('clientId' in options) { + return { + q: params.term, + client_id: options.clientId, + session: options.sessionId, + }; + } + return { + q: params.term, + }; + }, + processResults(data) { + let users = []; + if (data.status === 'ok') { + users = assembleUsers(data.users); + } + return { more: false, results: users }; + }, + }, + }); + }, + textAutocomplete(options) { + $(`#${options.id}`).select2({ + placeholder: 'Type to select', + multiple: options.multiple, + minimumInputLength: 2, + ajax: { + url: options.autocompleteEndpoint, + dataType: 'json', + data(params, page) { + return { + q: params.term, + page, + }; + }, + processResults(data) { + return { + more: false, + results: data[options.key].map((item) => { + return { id: item, text: item }; + }), + }; + }, + }, + }); + }, + geonameAutocomplete(options) { + $(options.selector).select2({ + placeholder: 'Search for a location', + multiple: true, + minimumInputLength: 2, + ajax: { + url: options.autocompleteEndpoint, + dataType: 'jsonp', + data(params) { + return { + q: params.term, + }; + }, + processResults(data) { + const rdata = []; + if (data.status === 'ok') { + for (let i = 0; i < data.result.length; i += 1) { + rdata.push({ + id: data.result[i].geonameid, + text: data.result[i].picker_title, + }); + } + } + return { more: false, results: rdata }; + }, + }, + }); + + // Setting label for Geoname ids + let val = $(options.selector).val(); + if (val) { + val = val.map((id) => { + return `name=${id}`; + }); + const qs = val.join('&'); + $.ajax(`${options.getnameEndpoint}?${qs}`, { + accepts: 'application/json', + dataType: 'jsonp', + }).done((data) => { + $(options.selector).empty(); + const rdata = []; + if (data.status === 'ok') { + for (let i = 0; i < data.result.length; i += 1) { + $(options.selector).append( + `` + ); + rdata.push(data.result[i].geonameid); + } + $(options.selector).val(rdata).trigger('change'); + } + }); + } + }, +}; + +export default EnableAutocompleteWidgets; diff --git a/funnel/assets/js/utils/bookmark.js b/funnel/assets/js/utils/bookmark.js index 682b443d1..395c210cc 100644 --- a/funnel/assets/js/utils/bookmark.js +++ b/funnel/assets/js/utils/bookmark.js @@ -1,3 +1,4 @@ +import toastr from 'toastr'; import Form from './formhelper'; const SaveProject = ({ @@ -20,18 +21,19 @@ const SaveProject = ({ $(this).addClass('animate-btn--show'); if ($(this).hasClass('animate-btn--saved')) { $(this).addClass('animate-btn--animate'); - window.toastr.success( - window.gettext('Project added to Account > Saved projects') - ); + toastr.success(window.gettext('Project added to Account > Saved projects')); } } }); Form.updateFormNonce(response); }; - const onError = (response) => Form.handleAjaxError(response); + const onError = (error) => { + const errorMsg = Form.handleAjaxError(error); + toastr.error(errorMsg); + }; - window.Hasgeek.Forms.handleFormSubmit(formId, postUrl, onSuccess, onError, config); + Form.handleFormSubmit(formId, postUrl, onSuccess, onError, config); }; export default SaveProject; diff --git a/funnel/assets/js/utils/codemirror.js b/funnel/assets/js/utils/codemirror.js index bf17af1d6..48f3278d7 100644 --- a/funnel/assets/js/utils/codemirror.js +++ b/funnel/assets/js/utils/codemirror.js @@ -30,6 +30,7 @@ function codemirrorHelper(markdownId, updateFnCallback = '', callbackInterval = const extensions = [ EditorView.lineWrapping, + EditorView.contentAttributes.of({ autocapitalize: 'on' }), closeBrackets(), history(), foldGutter(), @@ -54,6 +55,8 @@ function codemirrorHelper(markdownId, updateFnCallback = '', callbackInterval = } }, }); + + $(`#${markdownId}`).addClass('activated').removeClass('activating'); document.querySelector(`#${markdownId}`).parentNode.append(view.dom); return view; } diff --git a/funnel/assets/js/utils/codemirror_stylesheet.js b/funnel/assets/js/utils/codemirror_stylesheet.js new file mode 100644 index 000000000..88a88e4e0 --- /dev/null +++ b/funnel/assets/js/utils/codemirror_stylesheet.js @@ -0,0 +1,51 @@ +import { EditorView, keymap } from '@codemirror/view'; +import { css, cssLanguage } from '@codemirror/lang-css'; +import { closeBrackets } from '@codemirror/autocomplete'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { + syntaxHighlighting, + defaultHighlightStyle, + foldGutter, +} from '@codemirror/language'; + +function codemirrorStylesheetHelper( + textareaId, + updateFnCallback = '', + callbackInterval = 1000 +) { + let textareaWaitTimer; + + const extensions = [ + EditorView.lineWrapping, + EditorView.contentAttributes.of({ autocapitalize: 'on' }), + closeBrackets(), + history(), + foldGutter(), + syntaxHighlighting(defaultHighlightStyle), + keymap.of([defaultKeymap, historyKeymap]), + css({ base: cssLanguage }), + ]; + + const view = new EditorView({ + doc: $(`#${textareaId}`).val(), + extensions, + dispatch: (tr) => { + view.update([tr]); + $(`#${textareaId}`).val(view.state.doc.toString()); + if (updateFnCallback) { + if (textareaWaitTimer) clearTimeout(textareaWaitTimer); + textareaWaitTimer = setTimeout(() => { + updateFnCallback(view); + }, callbackInterval); + } + }, + }); + if ($(`#${textareaId}`).hasClass('activated')) { + $(`#${textareaId}`).next().remove(); + } + $(`#${textareaId}`).addClass('activated').removeClass('activating'); + document.querySelector(`#${textareaId}`).parentNode.append(view.dom); + return view; +} + +export default codemirrorStylesheetHelper; diff --git a/funnel/assets/js/utils/embedvideo.js b/funnel/assets/js/utils/embedvideo.js index 1ffbe1365..b02b0d743 100644 --- a/funnel/assets/js/utils/embedvideo.js +++ b/funnel/assets/js/utils/embedvideo.js @@ -8,7 +8,7 @@ const Video = { */ getVideoTypeAndId(url) { const regexMatch = url.match( - /(http:|https:|)\/\/(player.|www.)?(y2u\.be|vimeo\.com|youtu(be\.com|\.be|be\.googleapis\.com))\/(video\/|embed\/|watch\?v=|v\/)?([A-Za-z0-9._%-]*)(&\S+)?/ + /(http:|https:|)\/\/(player.|www.)?(y2u\.be|vimeo\.com|youtu(be\.com|\.be|be\.googleapis\.com))\/(video\/|embed\/|live\/|watch\?v=|v\/)?([A-Za-z0-9._%-]*)(&\S+)?/ ); let type = ''; if (regexMatch && regexMatch.length > 5) { diff --git a/funnel/assets/js/utils/form_widgets.js b/funnel/assets/js/utils/form_widgets.js new file mode 100644 index 000000000..cf2a54466 --- /dev/null +++ b/funnel/assets/js/utils/form_widgets.js @@ -0,0 +1,232 @@ +import toastr from 'toastr'; +import Form from './formhelper'; + +export const Widgets = { + activateToggleSwitch(checkboxId, callbckfn = '') { + function postForm() { + let submitting = false; + return (checkboxElem) => { + if (!submitting) { + submitting = true; + const checkbox = $(checkboxElem); + const currentState = checkboxElem.checked; + const previousState = !currentState; + const formData = new FormData(checkbox.parent('form')[0]); + if (!currentState) { + formData.append(checkbox.attr('name'), false); + } + + fetch(checkbox.parent('form').attr('action'), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(formData).toString(), + }) + .then((response) => response.json()) + .then((responseData) => { + if (responseData && responseData.message) { + toastr.success(responseData.message); + } + if (callbckfn) { + callbckfn(); + } + submitting = false; + }) + .catch((error) => { + const errorMsg = Form.handleAjaxError(error); + toastr.error(errorMsg); + checkbox.prop('checked', previousState); + submitting = false; + }); + } + }; + } + + const throttleSubmit = postForm(); + + $('body').on('change', checkboxId, function submitToggleSwitch() { + throttleSubmit(this); + }); + + $('body').on('click', '.js-dropdown-toggle', function stopPropagation(event) { + event.stopPropagation(); + }); + }, + openSubmissionToggle(checkboxId, cfpStatusDiv) { + const onSuccess = () => { + $(cfpStatusDiv).toggleClass('mui--hide'); + }; + this.activateToggleSwitch(checkboxId, onSuccess); + }, + handleDelete(elementClass, onSucessFn) { + $('body').on('click', elementClass, async function remove(event) { + event.preventDefault(); + const url = $(this).attr('data-href'); + const confirmationText = window.gettext('Are you sure you want to remove %s?', [ + $(this).attr('title'), + ]); + + /* eslint-disable no-alert */ + if (window.confirm(confirmationText)) { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + csrf_token: $('meta[name="csrf-token"]').attr('content'), + }).toString(), + }).catch(() => { + toastr.error(window.Hasgeek.Config.errorMsg.networkError); + }); + if (response && response.ok) { + const responseData = await response.json(); + if (responseData) { + onSucessFn(responseData); + } + } else { + Form.handleAjaxError(response); + } + } + }); + }, +}; + +export async function activateFormWidgets() { + $('.js-show-password').on('click', function showPassword(event) { + event.preventDefault(); + $(this).parent().find('.js-password-toggle').toggleClass('mui--hide'); + $(this).parent().find('input').attr('type', 'text'); + }); + $('.js-hide-password').on('click', function hidePassword(event) { + event.preventDefault(); + $(this).parent().find('.js-password-toggle').toggleClass('mui--hide'); + $(this).parent().find('input').attr('type', 'password'); + }); + + // Toggle between OTP/Password login + $('.js-toggle-login').on('click', function toggleOTPField(event) { + event.preventDefault(); + if ($(this).attr('id') === 'use-otp-login') { + $('.js-password-field').find('input').val(''); + } + $('.js-fields-toggle').toggleClass('mui--hide'); + }); + + $('.js-password-field input').on('change', function togglePasswordField() { + if ($('.js-password-field').hasClass('mui--hide')) { + $('.js-fields-toggle').toggleClass('mui--hide'); + } + }); + + // Change username field input mode to tel in login form + if ($('#loginformwrapper').length && $('#username').length) { + $('#username').attr('inputmode', 'tel'); + $('#username').attr('autocomplete', 'tel'); + $('.js-keyboard-switcher[data-inputmode="tel"]').addClass('active'); + } + + // Add support to toggle username field input mode between tel & email to change keyboard in mobile + $('.js-keyboard-switcher').on( + 'click touchstart touchend', + function keyboardSwitcher(event) { + event.preventDefault(); + const inputMode = $(this).data('inputmode'); + $('.js-keyboard-switcher').removeClass('active'); + $(this).addClass('active'); + $('#username').attr('inputmode', inputMode); + $('#username').attr('autocomplete', inputMode); + $('#username').blur(); + $('#username').focus(); + } + ); + + if ( + $( + 'textarea.markdown:not([style*="display: none"], .activating, .activated, .no-codemirror)' + ).length + ) { + const { default: codemirrorHelper } = await import('./codemirror'); + $( + 'textarea.markdown:not([style*="display: none"]:not(.activating):not(.activated)' + ).each(function enableCodemirror() { + const markdownId = $(this).attr('id'); + $(`#${markdownId}`).addClass('activating'); + codemirrorHelper(markdownId); + }); + } + + if ( + $( + 'textarea.stylesheet:not([style*="display: none"]:not(.activating):not(.activated)' + ).length + ) { + const { default: codemirrorStylesheetHelper } = await import( + './codemirror_stylesheet' + ); + $( + 'textarea.stylesheet:not([style*="display: none"]:not(.activating):not(.activated)' + ).each(function enableCodemirrorForStylesheet() { + const textareaId = $(this).attr('id'); + $(`#${textareaId}`).addClass('activating'); + codemirrorStylesheetHelper(textareaId); + }); + } +} + +export class MapMarker { + constructor(field) { + this.field = field; + this.activate(); + } + + activate() { + const self = this; + Form.preventSubmitOnEnter(this.field.locationId); + + // locationpicker.jquery.js + $(`#${this.field.mapId}`).locationpicker({ + location: self.getDefaultLocation(), + radius: 0, + zoom: 18, + inputBinding: { + latitudeInput: $(`#${this.field.latitudeId}`), + longitudeInput: $(`#${this.field.longitudeId}`), + locationNameInput: $(`#${this.field.locationId}`), + }, + enableAutocomplete: true, + onchanged() { + if ($(`#${self.field.locationId}`).val()) { + $(`#${self.field.mapId}`).removeClass('mui--hide'); + } + }, + onlocationnotfound() {}, + oninitialized() { + // Locationpicker sets latitude and longitude field value to 0, + // this is to empty the fields and hide the map + if (!$(`#${self.field.locationId}`).val()) { + $(`#${self.field.latitudeId}`).val(''); + $(`#${self.field.longitudeId}`).val(''); + $(`#${self.field.mapId}`).addClass('mui--hide'); + } + }, + }); + + // On clicking clear, empty latitude, longitude, location fields and hide map + $(`#${this.field.clearId}`).on('click', (event) => { + event.preventDefault(); + $(`#${self.field.latitudeId}`).val(''); + $(`#${self.field.longitudeId}`).val(''); + $(`#${self.field.locationId}`).val(''); + $(`#${self.field.mapId}`).addClass('mui--hide'); + }); + } + + getDefaultLocation() { + const latitude = $(`#${this.field.latitudeId}`).val(); + const longitude = $(`#${this.field.longitudeId}`).val(); + return { latitude, longitude }; + } +} diff --git a/funnel/assets/js/utils/formhelper.js b/funnel/assets/js/utils/formhelper.js index f47e700fd..9c957785d 100644 --- a/funnel/assets/js/utils/formhelper.js +++ b/funnel/assets/js/utils/formhelper.js @@ -52,7 +52,6 @@ const Form = { handleAjaxError(errorResponse) { Form.updateFormNonce(errorResponse.responseJSON); const errorMsg = Form.getResponseError(errorResponse); - window.toastr.error(errorMsg); return errorMsg; }, formErrorHandler(formId, errorResponse) { @@ -60,11 +59,6 @@ const Form = { $(`#${formId}`).find('.loading').addClass('mui--hide'); return Form.handleAjaxError(errorResponse); }, - handleFetchNetworkError() { - const errorMsg = window.Hasgeek.Config.errorMsg.networkError; - window.toastr.error(errorMsg); - return errorMsg; - }, getActionUrl(formId) { return $(`#${formId}`).attr('action'); }, @@ -73,123 +67,136 @@ const Form = { $('input[name="form_nonce"]').val(response.form_nonce); } }, - handleModalForm() { - $('.js-modal-form').click(function addModalToWindowHash() { - window.location.hash = $(this).data('hash'); - }); - - $('body').on($.modal.BEFORE_CLOSE, () => { - if (window.location.hash) { - window.history.replaceState( - '', - '', - window.location.pathname + window.location.search - ); + preventSubmitOnEnter(id) { + $(`#${id}`).on('keyup keypress', (e) => { + const code = e.keyCode || e.which; + if (code === 13) { + e.preventDefault(); + return false; } + return true; }); - - window.addEventListener( - 'hashchange', - () => { - if (window.location.hash === '') { - $.modal.close(); - } - }, - false - ); - - const hashId = window.location.hash.split('#')[1]; - if (hashId) { - if ($(`a.js-modal-form[data-hash="${hashId}"]`).length) { - $(`a[data-hash="${hashId}"]`).click(); - } - } }, - handleDelete(elementClass, onSucessFn) { - $('body').on('click', elementClass, async function remove(event) { - event.preventDefault(); - const url = $(this).attr('data-href'); - const confirmationText = window.gettext('Are you sure you want to remove %s?', [ - $(this).attr('title'), - ]); - - /* eslint-disable no-alert */ - if (window.confirm(confirmationText)) { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - csrf_token: $('meta[name="csrf-token"]').attr('content'), - }).toString(), - }).catch(Form.handleFetchNetworkError); - if (response && response.ok) { - const responseData = await response.json(); - if (responseData) { - onSucessFn(responseData); + preventDoubleSubmit(formId) { + const form = $(`#${formId}`); + form + .find('input[type="submit"]') + .prop('disabled', true) + .addClass('submit-disabled'); + form + .find('button[type="submit"]') + .prop('disabled', true) + .addClass('submit-disabled'); + form.find('.loading').removeClass('mui--hide'); + }, + /* Takes 'formId' and 'errors' + 'formId' is the id attribute of the form for which errors needs to be displayed + 'errors' is the WTForm validation errors expected in the following format + { + "title": [ + "This field is required" + ] + "email": [ + "Not a valid email" + ] + } + For each error, a 'p' tag is created if not present and + assigned the error value as its text content. + The field wrapper and field are queried in the DOM + using the unique form id. And the newly created 'p' tag + is inserted in the DOM below the field. + */ + showValidationErrors(formId, errors) { + const form = document.getElementById(formId); + Object.keys(errors).forEach((fieldName) => { + if (Array.isArray(errors[fieldName])) { + const fieldWrapper = form.querySelector(`#field-${fieldName}`); + if (fieldWrapper) { + let errorElem = fieldWrapper.querySelector('.mui-form__error'); + // If error P tag doesn't exist, create it + if (!errorElem) { + errorElem = document.createElement('p'); + errorElem.classList.add('mui-form__error'); } - } else { - Form.handleAjaxError(response); + [{ fieldName: errorElem.innerText }] = errors; + const field = form.querySelector(`#${fieldName}`); + // Insert the p tag below the field + field.parentNode.appendChild(errorElem); + // Add error class to field wrapper + fieldWrapper.classList.add('has-error'); } } }); }, - activateToggleSwitch(checkboxId, callbckfn = '') { - function postForm() { - let submitting = false; - return (checkboxElem) => { - if (!submitting) { - submitting = true; - const checkbox = $(checkboxElem); - const currentState = checkboxElem.checked; - const previousState = !currentState; - const formData = new FormData(checkbox.parent('form')[0]); - if (!currentState) { - formData.append(checkbox.attr('name'), false); - } - - fetch(checkbox.parent('form').attr('action'), { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams(formData).toString(), - }) - .then((responseData) => { - if (responseData && responseData.message) { - window.toastr.success(responseData.message); - } - if (callbckfn) { - callbckfn(); - } - submitting = false; - }) - .catch((error) => { - Form.handleAjaxError(error); - checkbox.prop('checked', previousState); - submitting = false; - }); - } - }; + showFormError(formid, error, alertBoxHtml) { + const form = $(`#${formid}`); + form + .find('input[type="submit"]') + .prop('disabled', false) + .removeClass('submit-disabled'); + form + .find('button[type="submit"]') + .prop('disabled', false) + .removeClass('submit-disabled'); + form.find('.loading').addClass('mui--hide'); + $('.alert').remove(); + form.append(alertBoxHtml); + if (error.readyState === 4) { + if (error.status === 500) { + $(form).find('.alert__text').text(window.Hasgeek.Config.errorMsg.serverError); + } else if (error.status === 429) { + $(form) + .find('.alert__text') + .text(window.Hasgeek.Config.errorMsg.rateLimitError); + } else if (error.responseJSON && error.responseJSON.error_description) { + $(form).find('.alert__text').text(error.responseJSON.error_description); + } else { + $(form).find('.alert__text').text(window.Hasgeek.Config.errorMsg.error); + } + } else { + $(form).find('.alert__text').text(window.Hasgeek.Config.errorMsg.networkError); } - - const throttleSubmit = postForm(); - - $('body').on('change', checkboxId, function submitToggleSwitch() { - throttleSubmit(this); - }); - - $('body').on('click', '.js-dropdown-toggle', function stopPropagation(event) { - event.stopPropagation(); + }, + ajaxFormSubmit(formId, url, onSuccess, onError, config) { + const formData = $(`#${formId}`).serialize(); + $.ajax({ + url, + type: 'POST', + data: config.formData ? config.formData : formData, + dataType: config.dataType ? config.dataType : 'json', + contentType: config.contentType + ? config.contentType + : 'application/x-www-form-urlencoded', + beforeSend() { + Form.preventDoubleSubmit(formId); + if (config.beforeSend) config.beforeSend(); + }, + success(responseData) { + if (onSuccess) onSuccess(responseData); + }, + error(xhr) { + onError(xhr); + }, }); }, - openSubmissionToggle(checkboxId, cfpStatusDiv) { - const onSuccess = () => { - $(cfpStatusDiv).toggleClass('mui--hide'); - }; - Form.activateToggleSwitch(checkboxId, onSuccess); + /* Takes formId, url, onSuccess, onError, config + 'formId' - Form id selector to query the DOM for the form + 'url' - The url to which the post request is sent + 'onSuccess' - A callback function that is executed if the request succeeds + 'onError' - A callback function that is executed if the request fails + 'config' - An object that can contain dataType, beforeSend function + handleFormSubmit handles form submit, serializes the form values, + disables the submit button to prevent double submit, + displays the loading indicator and submits the form via ajax. + On completing the ajax request, calls the onSuccess/onError callback function. + */ + handleFormSubmit(formId, url, onSuccess, onError, config) { + $(`#${formId}`) + .find('button[type="submit"]') + .click((event) => { + event.preventDefault(); + Form.ajaxFormSubmit(formId, url, onSuccess, onError, config); + }); }, }; diff --git a/funnel/assets/js/utils/getDevicePixelRatio.js b/funnel/assets/js/utils/getDevicePixelRatio.js new file mode 100644 index 000000000..a7ed04639 --- /dev/null +++ b/funnel/assets/js/utils/getDevicePixelRatio.js @@ -0,0 +1,15 @@ +/*! GetDevicePixelRatio | Author: Tyson Matanich, 2012 | License: MIT */ +(function setGlobalFn(n) { + /* eslint-disable no-return-assign */ + n.getDevicePixelRatio = function getRatio() { + let t = 1; + return ( + n.screen.systemXDPI !== undefined && + n.screen.logicalXDPI !== undefined && + n.screen.systemXDPI > n.screen.logicalXDPI + ? (t = n.screen.systemXDPI / n.screen.logicalXDPI) + : n.devicePixelRatio !== undefined && (t = n.devicePixelRatio), + t + ); + }; +})(window); diff --git a/funnel/assets/js/utils/getTimeago.js b/funnel/assets/js/utils/get_timeago.js similarity index 100% rename from funnel/assets/js/utils/getTimeago.js rename to funnel/assets/js/utils/get_timeago.js diff --git a/funnel/assets/js/utils/gettext.js b/funnel/assets/js/utils/gettext.js index 6c69a4b40..76a51be1c 100644 --- a/funnel/assets/js/utils/gettext.js +++ b/funnel/assets/js/utils/gettext.js @@ -57,6 +57,7 @@ // ], import { sprintf, vsprintf } from 'sprintf-js'; +import { AJAX_TIMEOUT } from '../constants'; class Gettext { constructor(config) { @@ -75,7 +76,7 @@ class Gettext { type: 'GET', url: this.getTranslationFileUrl(config.translatedLang), async: false, - timeout: window.Hasgeek.Config.ajaxTimeout, + timeout: AJAX_TIMEOUT, success(responseData) { domain = responseData.domain; catalog = responseData.locale_data.messages; @@ -85,7 +86,7 @@ class Gettext { type: 'GET', url: this.getBaseframeTranslationFileUrl(config.translatedLang), async: false, - timeout: window.Hasgeek.Config.ajaxTimeout, + timeout: AJAX_TIMEOUT, success(responseData) { catalog = Object.assign(catalog, responseData.locale_data.messages); }, @@ -99,15 +100,9 @@ class Gettext { if (msgid in this.catalog) { const msgidCatalog = this.catalog[msgid]; - if (msgidCatalog.length < 2) { - // eslint-disable-next-line no-console - console.error( - 'Invalid format for translated messages, at least 2 values expected' - ); + if (msgidCatalog[0] !== '') { + return vsprintf(msgidCatalog[0], args); } - // in case of gettext() first element is empty because it's the msgid_plural, - // and the second element is the translated msgstr - return vsprintf(msgidCatalog[1], args); } return vsprintf(msgid, args); }; @@ -116,27 +111,27 @@ class Gettext { if (msgid in this.catalog) { const msgidCatalog = this.catalog[msgid]; - if (msgidCatalog.length < 3) { + if (msgidCatalog.length < 2) { // eslint-disable-next-line no-console console.error( - 'Invalid format for translated messages, at least 3 values expected for plural translations' + 'Invalid format for translated messages, at least 2 values expected for plural translations' ); - } - - if (msgidPlural !== msgidCatalog[0]) { - // eslint-disable-next-line no-console - console.error("Plural forms don't match"); - } - - let msgstr = ''; - if (num <= 1) { - msgstr = sprintf(msgidCatalog[1], { num }); } else { - msgstr = sprintf(msgidCatalog[2], { num }); + let msgstr = ''; + if (num === 1) { + msgstr = sprintf(msgidCatalog[0], { num }); + } else { + msgstr = sprintf(msgidCatalog[1], { num }); + } + if (msgstr !== '') { + return vsprintf(msgstr, args); + } } - return vsprintf(msgstr, args); } - return vsprintf(msgid, args); + if (num === 1) { + return vsprintf(sprintf(msgid, { num }), args); + } + return vsprintf(sprintf(msgidPlural, { num }), args); }; } } diff --git a/funnel/assets/js/utils/helper.js b/funnel/assets/js/utils/helper.js index 541c78258..ecd116762 100644 --- a/funnel/assets/js/utils/helper.js +++ b/funnel/assets/js/utils/helper.js @@ -25,14 +25,6 @@ const Utils = { $(this).siblings('.collapsible__body').slideToggle(); }); }, - popupBackHandler() { - $('.js-popup-back').on('click', (event) => { - if (document.referrer !== '') { - event.preventDefault(); - window.history.back(); - } - }); - }, navSearchForm() { $('.js-search-show').on('click', function toggleSearchForm(event) { event.preventDefault(); @@ -50,108 +42,6 @@ const Utils = { } }); }, - headerMenuDropdown(menuBtnClass, menuWrapper, menu, url) { - const menuBtn = $(menuBtnClass); - const topMargin = 1; - const headerHeight = $('.header').height() + topMargin; - let page = 1; - let lazyLoader; - let observer; - - const openMenu = () => { - if ($(window).width() < window.Hasgeek.Config.mobileBreakpoint) { - $(menuWrapper).find(menu).animate({ top: '0' }); - } else { - $(menuWrapper).find(menu).animate({ top: headerHeight }); - } - $('.header__nav-links--active').addClass('header__nav-links--menuOpen'); - menuBtn.addClass('header__nav-links--active'); - $('body').addClass('body-scroll-lock'); - }; - - const closeMenu = () => { - if ($(window).width() < window.Hasgeek.Config.mobileBreakpoint) { - $(menuWrapper).find(menu).animate({ top: '100vh' }); - } else { - $(menuWrapper).find(menu).animate({ top: '-100vh' }); - } - menuBtn.removeClass('header__nav-links--active'); - $('body').removeClass('body-scroll-lock'); - $('.header__nav-links--active').removeClass('header__nav-links--menuOpen'); - }; - - const updatePageNumber = () => { - page += 1; - }; - - const fetchMenu = async (pageNo = 1) => { - const menuUrl = `${url}?${new URLSearchParams({ - page: pageNo, - }).toString()}`; - const response = await fetch(menuUrl, { - headers: { - 'X-Requested-With': 'XMLHttpRequest', - }, - }); - if (response && response.ok) { - const responseData = await response.text(); - if (responseData) { - if (observer) { - observer.unobserve(lazyLoader); - $('.js-load-comments').remove(); - } - $(menuWrapper).find(menu).append(responseData); - updatePageNumber(); - lazyLoader = document.querySelector('.js-load-comments'); - if (lazyLoader) { - observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - fetchMenu(page); - } - }); - }, - { - rootMargin: '0px', - threshold: 0, - } - ); - observer.observe(lazyLoader); - } - } - } - }; - - // If user logged in, preload menu - if ($(menuWrapper).length) { - fetchMenu(); - } - - // Open full screen account menu in mobile - menuBtn.on('click', function clickOpenCloseMenu() { - if ($(this).hasClass('header__nav-links--active')) { - closeMenu(); - } else { - openMenu(); - } - }); - - $('body').on('click', (event) => { - const totalBtn = $(menuBtn).toArray(); - let isChildElem = false; - totalBtn.forEach((element) => { - isChildElem = isChildElem || $.contains(element, event.target); - }); - if ( - $(menuBtn).hasClass('header__nav-links--active') && - !$(event.target).is(menuBtn) && - !isChildElem - ) { - closeMenu(); - } - }); - }, truncate() { const readMoreTxt = `…${gettext( 'read more' @@ -246,139 +136,6 @@ const Utils = { } }); }, - setNotifyIcon(unread) { - if (unread) { - $('.header__nav-links--updates').addClass('header__nav-links--updates--unread'); - } else { - $('.header__nav-links--updates').removeClass( - 'header__nav-links--updates--unread' - ); - } - }, - async updateNotificationStatus() { - const response = await fetch(window.Hasgeek.Config.notificationCount, { - headers: { - Accept: 'application/x.html+json', - 'X-Requested-With': 'XMLHttpRequest', - }, - }); - if (response && response.ok) { - const responseData = await response.json(); - Utils.setNotifyIcon(responseData.unread); - } - }, - async sendNotificationReadStatus() { - const notificationID = this.getQueryString('utm_source'); - const Base58regex = /[\d\w]{21,22}/; - - if (notificationID && Base58regex.test(notificationID)) { - const url = window.Hasgeek.Config.markNotificationReadUrl.replace( - 'eventid_b58', - notificationID - ); - const response = await fetch(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - csrf_token: $('meta[name="csrf-token"]').attr('content'), - }).toString(), - }); - if (response && response.ok) { - const responseData = await response.json(); - if (responseData) { - Utils.setNotifyIcon(responseData.unread); - } - } - } - }, - addWebShare() { - const utils = this; - if (navigator.share) { - $('.project-links').hide(); - $('.hg-link-btn').removeClass('mui--hide'); - - const mobileShare = (title, url, text) => { - navigator.share({ - title, - url, - text, - }); - }; - - $('body').on('click', '.hg-link-btn', function clickWebShare(event) { - event.preventDefault(); - const linkElem = this; - let url = - $(linkElem).data('url') || - (document.querySelector('link[rel=canonical]') && - document.querySelector('link[rel=canonical]').href) || - window.location.href; - const title = $(this).data('title') || document.title; - const text = $(this).data('text') || ''; - if ($(linkElem).attr('data-shortlink')) { - mobileShare(title, url, text); - } else { - utils - .fetchShortUrl(url) - .then((shortlink) => { - url = shortlink; - $(linkElem).attr('data-shortlink', true); - }) - .finally(() => { - mobileShare(title, url, text); - }); - } - }); - } else { - $('body').on('click', '.js-copy-link', function clickCopyLink(event) { - event.preventDefault(); - const linkElem = this; - const copyLink = () => { - const url = $(linkElem).find('.js-copy-url').first().text(); - if (navigator.clipboard) { - navigator.clipboard.writeText(url).then( - () => window.toastr.success(gettext('Link copied')), - () => window.toastr.success(gettext('Could not copy link')) - ); - } else { - const selection = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents($(linkElem).find('.js-copy-url')[0]); - selection.removeAllRanges(); - selection.addRange(range); - if (document.execCommand('copy')) { - window.toastr.success(gettext('Link copied')); - } else { - window.toastr.success(gettext('Could not copy link')); - } - selection.removeAllRanges(); - } - }; - if ($(linkElem).attr('data-shortlink')) { - copyLink(); - } else { - utils - .fetchShortUrl($(linkElem).find('.js-copy-url').first().html()) - .then((shortlink) => { - $(linkElem).find('.js-copy-url').text(shortlink); - $(linkElem).attr('data-shortlink', true); - }) - .finally(() => { - copyLink(); - }); - } - }); - } - }, - enableWebShare() { - if (navigator.share) { - $('.project-links').hide(); - $('.hg-link-btn').removeClass('mui--hide'); - } - }, async fetchShortUrl(url) { const response = await fetch(window.Hasgeek.Config.shorturlApi, { method: 'POST', @@ -387,13 +144,14 @@ const Utils = { 'Content-Type': 'application/x-www-form-urlencoded', }, body: `url=${encodeURIComponent(url)}`, + }).catch(() => { + throw new Error(window.Hasgeek.Config.errorMsg.serverError); }); if (response.ok) { const json = await response.json(); return json.shortlink; } - // Call failed, return the original URL - return url; + return Promise.reject(window.gettext('This URL is not valid for a shortlink')); }, getQueryString(paramName) { const urlParams = new URLSearchParams(window.location.search); @@ -402,6 +160,33 @@ const Utils = { } return false; }, + getInitials(name) { + if (name) { + const parts = name.split(/\s+/); + const len = parts.length; + if (len > 1) { + return ( + (parts[0] ? parts[0][0] : '') + (parts[len - 1] ? parts[len - 1][0] : '') + ); + } + if (parts) { + return parts[0] ? parts[0][0] : ''; + } + } + return ''; + }, + getAvatarColour(name) { + const avatarColorCount = 6; + const initials = this.getInitials(name); + let stringTotal = 0; + if (initials.length) { + stringTotal = initials.charCodeAt(0); + if (initials.length > 1) { + stringTotal += initials.charCodeAt(1); + } + } + return stringTotal % avatarColorCount; + }, getFaiconHTML(icon, iconSize = 'body', baseline = true, cssClassArray = []) { const svgElem = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const useElem = document.createElementNS('http://www.w3.org/2000/svg', 'use'); @@ -421,6 +206,27 @@ const Utils = { svgElem.classList.add('fa5-icon', ...cssClassArray); return svgElem; }, + debounce(fn, timeout, context, ...args) { + let timer = null; + function debounceFn() { + if (timer) clearTimeout(timer); + const fnContext = context || this; + timer = setTimeout(fn.bind(fnContext, ...args), timeout); + } + return debounceFn; + }, + csrfRefresh() { + $.ajax({ + type: 'GET', + url: '/api/baseframe/1/csrf/refresh', + timeout: 5000, + dataType: 'json', + success(data) { + $('meta[name="csrf-token"]').attr('content', data.csrf_token); + $('input[name="csrf_token"]').val(data.csrf_token); + }, + }); + }, }; export default Utils; diff --git a/funnel/assets/js/utils/initembed.js b/funnel/assets/js/utils/initembed.js index 581870993..71a8e9d8f 100644 --- a/funnel/assets/js/utils/initembed.js +++ b/funnel/assets/js/utils/initembed.js @@ -3,6 +3,7 @@ import TypeformEmbed from './typeform_embed'; import MarkmapEmbed from './markmap'; import addMermaidEmbed from './mermaid'; import PrismEmbed from './prism'; +import Tabs from './tabs'; export default function initEmbed(parentContainer = '.markdown') { TypeformEmbed.init(parentContainer); @@ -10,4 +11,5 @@ export default function initEmbed(parentContainer = '.markdown') { MarkmapEmbed.init(parentContainer); addMermaidEmbed(parentContainer); PrismEmbed.init(parentContainer); + Tabs.init(parentContainer); } diff --git a/funnel/assets/js/utils/jsonform.js b/funnel/assets/js/utils/jsonform.js new file mode 100644 index 000000000..c76b2954a --- /dev/null +++ b/funnel/assets/js/utils/jsonform.js @@ -0,0 +1,51 @@ +import Vue from 'vue/dist/vue.min'; +import Form from './formhelper'; + +const jsonForm = Vue.component('jsonform', { + template: '#form-template', + props: ['jsonschema', 'title', 'formid'], + methods: { + getFormData() { + const obj = {}; + const formData = $(`#${this.formid}`).serializeArray(); + formData.forEach((field) => { + if (field.name !== 'form_nonce' && field.name !== 'csrf_token') + obj[field.name] = field.value; + }); + return JSON.stringify(obj); + }, + activateForm() { + const form = this; + const url = Form.getActionUrl(this.formid); + const formValues = new FormData($(`#${this.formid}`)[0]); + const onSuccess = (response) => { + this.$emit('handle-submit-response', this.formid, response); + }; + const onError = (response) => { + Form.formErrorHandler(this.formid, response); + }; + $(`#${this.formid}`) + .find('button[type="submit"]') + .click((event) => { + event.preventDefault(); + Form.ajaxFormSubmit(this.formid, url, onSuccess, onError, { + contentType: 'application/json', + dataType: 'html', + formData: JSON.stringify({ + form_nonce: formValues.get('form_nonce'), + csrf_token: formValues.get('csrf_token'), + form: form.getFormData(), + }), + }); + }); + }, + getFieldId() { + return Math.random().toString(16).slice(2); + }, + }, + mounted() { + this.activateForm(); + }, +}); + +export default jsonForm; diff --git a/funnel/assets/js/utils/lazyloadimage.js b/funnel/assets/js/utils/lazyloadimage.js index d6a0e7787..398b85736 100644 --- a/funnel/assets/js/utils/lazyloadimage.js +++ b/funnel/assets/js/utils/lazyloadimage.js @@ -1,5 +1,36 @@ const LazyloadImg = { init(imgClassName) { + const intersectionObserverComponents = function intersectionObserverComponents() { + LazyloadImg.addObserver(imgClassName); + }; + + if (document.querySelector(`.${imgClassName}`)) { + if ( + !( + 'IntersectionObserver' in global && + 'IntersectionObserverEntry' in global && + 'intersectionRatio' in IntersectionObserverEntry.prototype + ) + ) { + const polyfill = document.createElement('script'); + polyfill.setAttribute('type', 'text/javascript'); + polyfill.setAttribute( + 'src', + 'https://cdn.polyfill.io/v2/polyfill.min.js?features=IntersectionObserver' + ); + polyfill.onload = function loadintersectionObserverComponents() { + intersectionObserverComponents(); + }; + document.head.appendChild(polyfill); + } else { + intersectionObserverComponents(); + } + } + }, + displayImages(img) { + img.target.src = img.target.dataset.src; + }, + addObserver(imgClassName) { this.imgItems = [...document.querySelectorAll(`.${imgClassName}`)]; this.imgItems.forEach((img) => { if (img) { @@ -7,7 +38,7 @@ const LazyloadImg = { (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { - entry.target.src = entry.target.dataset.src; + LazyloadImg.displayImages(entry); observer = observer.disconnect(); } }); diff --git a/funnel/assets/js/utils/lazyloadmenu.js b/funnel/assets/js/utils/lazyloadmenu.js new file mode 100644 index 000000000..637dc0075 --- /dev/null +++ b/funnel/assets/js/utils/lazyloadmenu.js @@ -0,0 +1,124 @@ +import { MOBILE_BREAKPOINT } from '../constants'; + +const LazyLoadMenu = { + headerMenuDropdown(menuBtnClass, menuWrapper, menu, url) { + const menuBtn = $(menuBtnClass); + const topMargin = 1; + const headerHeight = $('.header').height() + topMargin; + let page = 1; + let lazyLoader; + let observer; + + const openMenu = () => { + if ($(window).width() < MOBILE_BREAKPOINT) { + $(menuWrapper).find(menu).animate({ top: '0' }); + } else { + $(menuWrapper).find(menu).animate({ top: headerHeight }); + } + $('.header__nav-links--active').addClass('header__nav-links--menuOpen'); + menuBtn.addClass('header__nav-links--active'); + $('body').addClass('body-scroll-lock'); + }; + + const closeMenu = () => { + if ($(window).width() < MOBILE_BREAKPOINT) { + $(menuWrapper).find(menu).animate({ top: '100vh' }); + } else { + $(menuWrapper).find(menu).animate({ top: '-100vh' }); + } + menuBtn.removeClass('header__nav-links--active'); + $('body').removeClass('body-scroll-lock'); + $('.header__nav-links--active').removeClass('header__nav-links--menuOpen'); + }; + + const updatePageNumber = () => { + page += 1; + }; + + const fetchMenu = async (pageNo = 1) => { + const menuUrl = `${url}?${new URLSearchParams({ + page: pageNo, + }).toString()}`; + const response = await fetch(menuUrl, { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (response && response.ok) { + const responseData = await response.text(); + if (responseData) { + if (observer) { + observer.unobserve(lazyLoader); + $('.js-load-comments').remove(); + } + $(menuWrapper).find(menu).append(responseData); + updatePageNumber(); + lazyLoader = document.querySelector('.js-load-comments'); + if (lazyLoader) { + observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + fetchMenu(page); + } + }); + }, + { + rootMargin: '0px', + threshold: 0, + } + ); + observer.observe(lazyLoader); + } + } + } + }; + + // If user logged in, preload menu + if ($(menuWrapper).length) { + fetchMenu(); + } + + // Open full screen account menu in mobile + menuBtn.on('click', function clickOpenCloseMenu() { + if ($(this).hasClass('header__nav-links--active')) { + closeMenu(); + } else { + openMenu(); + } + }); + + $('body').on('click', (event) => { + const totalBtn = $(menuBtn).toArray(); + let isChildElem = false; + totalBtn.forEach((element) => { + isChildElem = isChildElem || $.contains(element, event.target); + }); + if ( + $(menuBtn).hasClass('header__nav-links--active') && + !$(event.target).is(menuBtn) && + !isChildElem + ) { + closeMenu(); + } + }); + }, + init() { + LazyLoadMenu.headerMenuDropdown( + '.js-menu-btn', + '.js-account-menu-wrapper', + '.js-account-menu', + window.Hasgeek.Config.accountMenu + ); + if (window.Hasgeek.Config.commentSidebarElem) { + LazyLoadMenu.headerMenuDropdown( + '.js-comments-btn', + '.js-comments-wrapper', + '.js-comment-sidebar', + window.Hasgeek.Config.unreadCommentUrl + ); + } + }, +}; + +export default LazyLoadMenu; diff --git a/funnel/assets/js/utils/markmap.js b/funnel/assets/js/utils/markmap.js index 41b7ca758..137f9ab72 100644 --- a/funnel/assets/js/utils/markmap.js +++ b/funnel/assets/js/utils/markmap.js @@ -14,13 +14,23 @@ const MarkmapEmbed = { const parentElement = $(container || 'body'); const markmapEmbed = this; if ( - parentElement.find('.md-embed-markmap:not(.activating):not(.activated)').length > - 0 + parentElement.find('.md-embed-markmap:not(.activating, .activated)').length > 0 ) { const { Transformer } = await import('markmap-lib'); const { Markmap } = await import('markmap-view'); const transformer = new Transformer(); + const observer = new IntersectionObserver( + (items) => { + items.forEach((item) => { + if (item.isIntersecting) $(item.target).data('markmap').fit(); + }); + }, + { + root: $('.main-content')[0], + } + ); + parentElement .find('.md-embed-markmap:not(.activating):not(.activated)') .each(function embedMarkmap() { @@ -32,8 +42,19 @@ const MarkmapEmbed = { ); $(markdownDiv).find('.embed-container').append(''); const current = $(markdownDiv).find('svg')[0]; - const markmap = Markmap.create(current, { initialExpandLevel: 1 }, root); + const markmap = Markmap.create( + current, + { + autoFit: true, + pan: false, + fitRatio: 0.85, + initialExpandLevel: 1, + }, + root + ); markmapEmbed.markmaps.push(markmap); + $(current).data('markmap', markmap); + observer.observe(current); $(markdownDiv).addClass('activated').removeClass('activating'); }); diff --git a/funnel/assets/js/utils/mermaid.js b/funnel/assets/js/utils/mermaid.js index e10bef296..99ed18b71 100644 --- a/funnel/assets/js/utils/mermaid.js +++ b/funnel/assets/js/utils/mermaid.js @@ -1,8 +1,6 @@ async function addMermaidEmbed(container) { const parentElement = $(container || 'body'); - if ( - parentElement.find('.md-embed-mermaid:not(.activating):not(.activated)').length > 0 - ) { + if (parentElement.find('.md-embed-mermaid:not(.activating, .activated)').length > 0) { const { default: mermaid } = await import('mermaid'); let idCount = $('.md-embed-mermaid.activating, .md-embed-mermaid.activated').length; const idMarker = 'mermaid_elem_'; diff --git a/funnel/assets/js/utils/modalhelper.js b/funnel/assets/js/utils/modalhelper.js new file mode 100644 index 000000000..1e232f562 --- /dev/null +++ b/funnel/assets/js/utils/modalhelper.js @@ -0,0 +1,119 @@ +const Modal = { + handleModalForm() { + $('.js-modal-form').click(function addModalToWindowHash() { + window.location.hash = $(this).data('hash'); + }); + + $('body').on($.modal.BEFORE_CLOSE, () => { + if (window.location.hash) { + window.history.replaceState( + '', + '', + window.location.pathname + window.location.search + ); + } + }); + + window.addEventListener( + 'hashchange', + () => { + if (window.location.hash === '') { + $.modal.close(); + } + }, + false + ); + + const hashId = window.location.hash.split('#')[1]; + if (hashId) { + if ($(`a.js-modal-form[data-hash="${hashId}"]`).length) { + $(`a[data-hash="${hashId}"]`).click(); + } + } + + $('body').on('click', '.alert__close', function closeModal() { + $(this).parents('.alert').fadeOut(); + }); + }, + trapFocusWithinModal(modal) { + const $this = $(modal); + const focusableElems = + 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]'; + const children = $this.find('*'); + const focusableItems = children.filter(focusableElems).filter(':visible'); + const numberOfFocusableItems = focusableItems.length; + let focusedItem; + let focusedItemIndex; + $this.find('.modal__close').focus(); + + $this.on('keydown', (event) => { + if (event.keyCode !== 9) return; + focusedItem = $(document.activeElement); + focusedItemIndex = focusableItems.index(focusedItem); + if (!event.shiftKey && focusedItemIndex === numberOfFocusableItems - 1) { + focusableItems.get(0).focus(); + event.preventDefault(); + } + if (event.shiftKey && focusedItemIndex === 0) { + focusableItems.get(numberOfFocusableItems - 1).focus(); + event.preventDefault(); + } + }); + }, + addFocusOnModalShow() { + let focussedElem; + $('body').on($.modal.OPEN, '.modal', function moveFocusToModal() { + focussedElem = document.activeElement; + Modal.trapFocusWithinModal(this); + }); + + $('body').on($.modal.CLOSE, '.modal', () => { + focussedElem.focus(); + }); + }, + activateZoomPopup() { + if ($('.markdown').length > 0) { + $('abbr').each(function alignToolTip() { + if ($(this).offset().left > $(window).width() * 0.7) { + $(this).addClass('tooltip-right'); + } + }); + } + + $('body').on( + 'click', + '.markdown table, .markdown img', + function openTableInModal(event) { + event.preventDefault(); + $('body').append('
'); + $('.markdown-modal').html($(this)[0].outerHTML); + $('.markdown-modal').modal(); + } + ); + + $('body').on('click', '.markdown table a', (event) => { + event.stopPropagation(); + }); + + $('body').on($.modal.AFTER_CLOSE, '.markdown-modal', (event) => { + event.preventDefault(); + $('.markdown-modal').remove(); + }); + }, + popupBackHandler() { + $('.js-popup-back').on('click', (event) => { + if (document.referrer !== '') { + event.preventDefault(); + window.history.back(); + } + }); + }, + addUsability() { + this.handleModalForm(); + this.activateZoomPopup(); + this.addFocusOnModalShow(); + this.popupBackHandler(); + }, +}; + +export default Modal; diff --git a/funnel/assets/js/utils/prism.js b/funnel/assets/js/utils/prism.js index 798cae4cd..7f58004fb 100644 --- a/funnel/assets/js/utils/prism.js +++ b/funnel/assets/js/utils/prism.js @@ -1,80 +1,39 @@ +import Prism from 'prismjs'; +import 'prismjs/plugins/autoloader/prism-autoloader'; +import 'prismjs/plugins/match-braces/prism-match-braces'; +import 'prismjs/plugins/toolbar/prism-toolbar'; +import 'prismjs/plugins/copy-to-clipboard/prism-copy-to-clipboard'; + +Prism.plugins.autoloader.languages_path = '/static/build/js/prismjs/components/'; + const PrismEmbed = { activatePrism() { this.container - .find('code[class*=language-]:not(.activated):not(.activating)') + .find('code[class*=language-]:not(.activated, .activating)') .each(function activate() { - window.Prism.highlightElement(this); - }); - }, - hooked: false, - loadPrism() { - const CDN_CSS = [ - 'https://unpkg.com/prismjs/themes/prism.min.css', - // 'https://unpkg.com/prismjs/plugins/line-numbers/prism-line-numbers.min.css', - 'https://unpkg.com/prismjs/plugins/match-braces/prism-match-braces.min.css', - ]; - const CDN = [ - 'https://unpkg.com/prismjs/components/prism-core.min.js', - 'https://unpkg.com/prismjs/plugins/autoloader/prism-autoloader.min.js', - 'https://unpkg.com/prismjs/plugins/match-braces/prism-match-braces.min.js', - // 'https://unpkg.com/prismjs/plugins/line-numbers/prism-line-numbers.min.js', - 'https://unpkg.com/prismjs/plugins/toolbar/prism-toolbar.min.js', - // 'https://unpkg.com/prismjs/plugins/show-language/prism-show-language.min.js', - 'https://unpkg.com/prismjs/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js', - ]; - let asset = 0; - const loadPrismStyle = () => { - for (let i = 0; i < CDN_CSS.length; i += 1) { - if (!$(`link[href*="${CDN_CSS[i]}"]`).length) - $('head').append($(``)); - } - }; - const loadPrismScript = () => { - $.ajax({ - url: CDN[asset], - dataType: 'script', - cache: true, - }).done(() => { - if (asset < CDN.length - 1) { - asset += 1; - loadPrismScript(); - } else { - if (!this.hooked) { - this.hooked = true; - window.Prism.hooks.add('before-sanity-check', (env) => { - if (env.element) $(env.element).addClass('activating'); - }); - window.Prism.hooks.add('complete', (env) => { - if (env.element) - $(env.element).addClass('activated').removeClass('activating'); - $(env.element) - .parent() - .parent() - .find('.toolbar-item') - .find('a, button') - .addClass('mui-btn mui-btn--accent mui-btn--raised mui-btn--small'); - }); - $('body') - // .addClass('line-numbers') - .addClass('match-braces') - .addClass('rainbow-braces'); - } - this.activatePrism(); - } + Prism.highlightElement(this); }); - }; - loadPrismStyle(); - if (!window.Prism) { - loadPrismScript(); - } else this.activatePrism(); }, init(container) { this.container = $(container || 'body'); if ( - this.container.find('code[class*=language-]:not(.activated):not(.activating)') + this.container.find('code[class*=language-]:not(.activated, .activating)') .length > 0 ) { - this.loadPrism(); + Prism.hooks.add('before-sanity-check', (env) => { + if (env.element) $(env.element).addClass('activating'); + }); + Prism.hooks.add('complete', (env) => { + if (env.element) $(env.element).addClass('activated').removeClass('activating'); + $(env.element) + .parent() + .parent() + .find('.toolbar-item') + .find('a, button') + .addClass('mui-btn mui-btn--accent mui-btn--raised mui-btn--small'); + }); + $('body').addClass('match-braces').addClass('rainbow-braces'); + this.activatePrism(); } }, }; diff --git a/funnel/assets/js/utils/ractive_util.js b/funnel/assets/js/utils/ractive_util.js index 247d03da9..e0bf24a85 100644 --- a/funnel/assets/js/utils/ractive_util.js +++ b/funnel/assets/js/utils/ractive_util.js @@ -1,20 +1,22 @@ import Ractive from 'ractive'; +import Utils from './helper'; +import { USER_AVATAR_IMG_SIZE } from '../constants'; Ractive.DEBUG = false; export const useravatar = Ractive.extend({ - template: `{{#if user.profile_url && addprofilelink }}{{#if user.avatar }}{{else}}
{{ getInitials(user.fullname) }}
{{/if}}
{{else}}{{#if user.avatar }}{{else}}
{{ getInitials(user.fullname) }}
{{/if}}
{{/if}}`, + template: `{{#if user.profile_url && addprofilelink }}{{#if user.logo_url }}{{else}}
{{ getInitials(user.fullname) }}
{{/if}}
{{else}}{{#if user.logo_url }}{{else}}
{{ getInitials(user.fullname) }}
{{/if}}
{{/if}}`, data: { addprofilelink: true, size: 'medium', - getInitials: window.Hasgeek.Utils.getInitials, + getInitials: Utils.getInitials, imgurl() { - return `${this.get('user').avatar}?size=${encodeURIComponent( - window.Hasgeek.Config.userAvatarImgSize[this.get('size')] + return `${this.get('user').logo_url}?size=${encodeURIComponent( + USER_AVATAR_IMG_SIZE[this.get('size')] )}`; }, getAvatarColour(name) { - return window.Hasgeek.Utils.getAvatarColour(name); + return Utils.getAvatarColour(name); }, }, }); diff --git a/funnel/assets/js/utils/read_status.js b/funnel/assets/js/utils/read_status.js new file mode 100644 index 000000000..71fe7e1e3 --- /dev/null +++ b/funnel/assets/js/utils/read_status.js @@ -0,0 +1,167 @@ +import { MOBILE_BREAKPOINT, NOTIFICATION_REFRESH_INTERVAL } from '../constants'; +import Utils from './helper'; + +const ReadStatus = { + setNotifyIcon(unread) { + if (unread) { + $('.header__nav-links--updates').addClass('header__nav-links--updates--unread'); + } else { + $('.header__nav-links--updates').removeClass( + 'header__nav-links--updates--unread' + ); + } + }, + async updateNotificationStatus() { + const response = await fetch(window.Hasgeek.Config.notificationCount, { + headers: { + Accept: 'application/x.html+json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (response && response.ok) { + const responseData = await response.json(); + ReadStatus.setNotifyIcon(responseData.unread); + } + }, + async sendNotificationReadStatus() { + const notificationID = Utils.getQueryString('utm_source'); + const Base58regex = /[\d\w]{21,22}/; + + if (notificationID && Base58regex.test(notificationID)) { + const url = window.Hasgeek.Config.markNotificationReadUrl.replace( + 'eventid_b58', + notificationID + ); + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + csrf_token: $('meta[name="csrf-token"]').attr('content'), + }).toString(), + }); + if (response && response.ok) { + const responseData = await response.json(); + if (responseData) { + ReadStatus.setNotifyIcon(responseData.unread); + } + } + } + }, + headerMenuDropdown(menuBtnClass, menuWrapper, menu, url) { + const menuBtn = $(menuBtnClass); + const topMargin = 1; + const headerHeight = $('.header').height() + topMargin; + let page = 1; + let lazyLoader; + let observer; + + const openMenu = () => { + if ($(window).width() < MOBILE_BREAKPOINT) { + $(menuWrapper).find(menu).animate({ top: '0' }); + } else { + $(menuWrapper).find(menu).animate({ top: headerHeight }); + } + $('.header__nav-links--active').addClass('header__nav-links--menuOpen'); + menuBtn.addClass('header__nav-links--active'); + $('body').addClass('body-scroll-lock'); + }; + + const closeMenu = () => { + if ($(window).width() < MOBILE_BREAKPOINT) { + $(menuWrapper).find(menu).animate({ top: '100vh' }); + } else { + $(menuWrapper).find(menu).animate({ top: '-100vh' }); + } + menuBtn.removeClass('header__nav-links--active'); + $('body').removeClass('body-scroll-lock'); + $('.header__nav-links--active').removeClass('header__nav-links--menuOpen'); + }; + + const updatePageNumber = () => { + page += 1; + }; + + const fetchMenu = async (pageNo = 1) => { + const menuUrl = `${url}?${new URLSearchParams({ + page: pageNo, + }).toString()}`; + const response = await fetch(menuUrl, { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (response && response.ok) { + const responseData = await response.text(); + if (responseData) { + if (observer) { + observer.unobserve(lazyLoader); + $('.js-load-comments').remove(); + } + $(menuWrapper).find(menu).append(responseData); + updatePageNumber(); + lazyLoader = document.querySelector('.js-load-comments'); + if (lazyLoader) { + observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + fetchMenu(page); + } + }); + }, + { + rootMargin: '0px', + threshold: 0, + } + ); + observer.observe(lazyLoader); + } + } + } + }; + + // If user logged in, preload menu + if ($(menuWrapper).length) { + fetchMenu(); + } + + // Open full screen account menu in mobile + menuBtn.on('click', function clickOpenCloseMenu() { + if ($(this).hasClass('header__nav-links--active')) { + closeMenu(); + } else { + openMenu(); + } + }); + + $('body').on('click', (event) => { + const totalBtn = $(menuBtn).toArray(); + let isChildElem = false; + totalBtn.forEach((element) => { + isChildElem = isChildElem || $.contains(element, event.target); + }); + if ( + $(menuBtn).hasClass('header__nav-links--active') && + !$(event.target).is(menuBtn) && + !isChildElem + ) { + closeMenu(); + } + }); + }, + init() { + ReadStatus.sendNotificationReadStatus(); + if ($('.header__nav-links--updates').length) { + ReadStatus.updateNotificationStatus(); + window.setInterval( + ReadStatus.updateNotificationStatus, + NOTIFICATION_REFRESH_INTERVAL + ); + } + }, +}; + +export default ReadStatus; diff --git a/funnel/assets/js/utils/scrollhelper.js b/funnel/assets/js/utils/scrollhelper.js index f96d152dd..068ef821e 100644 --- a/funnel/assets/js/utils/scrollhelper.js +++ b/funnel/assets/js/utils/scrollhelper.js @@ -1,3 +1,5 @@ +import { MOBILE_BREAKPOINT } from '../constants'; + const ScrollHelper = { animateScrollTo(offsetY) { $('html,body').animate( @@ -14,7 +16,7 @@ const ScrollHelper = { }, getPageHeaderHeight() { let headerHeight; - if ($(window).width() < window.Hasgeek.Config.mobileBreakpoint) { + if ($(window).width() < MOBILE_BREAKPOINT) { headerHeight = $('.mobile-nav').height(); } else { headerHeight = $('header').height() + $('nav').height(); diff --git a/funnel/assets/js/utils/sort.js b/funnel/assets/js/utils/sort.js index 9ccd7b194..5a879eff7 100644 --- a/funnel/assets/js/utils/sort.js +++ b/funnel/assets/js/utils/sort.js @@ -1,6 +1,7 @@ import 'jquery-ui'; import 'jquery-ui-sortable-npm'; import 'jquery-ui-touch-punch'; +import toastr from 'toastr'; import Form from './formhelper'; function SortItem(wrapperJqueryElem, placeholderClass, sortUrl) { @@ -30,9 +31,10 @@ function SortItem(wrapperJqueryElem, placeholderClass, sortUrl) { function handleError(error) { if (!error.response) { - Form.handleFetchNetworkError(); + toastr.error(window.Hasgeek.Config.errorMsg.networkError); } else { - Form.handleAjaxError(error); + const errorMsg = Form.handleAjaxError(error); + toastr.error(errorMsg); } wrapperJqueryElem.sortable('cancel'); } diff --git a/funnel/assets/js/utils/spahelper.js b/funnel/assets/js/utils/spahelper.js index 7072480f3..0cfec5fb5 100644 --- a/funnel/assets/js/utils/spahelper.js +++ b/funnel/assets/js/utils/spahelper.js @@ -1,3 +1,4 @@ +import toastr from 'toastr'; import Form from './formhelper'; const Spa = { @@ -65,7 +66,7 @@ const Spa = { }, handleError(error) { const errorMsg = Form.getFetchError(error); - window.toastr.error(errorMsg); + toastr.error(errorMsg); }, async fetchPage(url, currentNavId, updateHistory) { const response = await fetch(url, { @@ -73,7 +74,9 @@ const Spa = { Accept: 'application/x.html+json', 'X-Requested-With': 'XMLHttpRequest', }, - }).catch(Form.handleFetchNetworkError); + }).catch(() => { + toastr.error(window.Hasgeek.Config.errorMsg.networkError); + }); if (response && response.ok) { const responseData = await response.json(); if (responseData) { diff --git a/funnel/assets/js/utils/tabs.js b/funnel/assets/js/utils/tabs.js new file mode 100644 index 000000000..1a3259cc5 --- /dev/null +++ b/funnel/assets/js/utils/tabs.js @@ -0,0 +1,230 @@ +import Utils from './helper'; + +const Tabs = { + overflowObserver: new ResizeObserver(function checkOverflow(entries) { + entries.forEach((entry) => { + if (Tabs.helpers.hasOverflow(entry.target)) + $(entry.target).parent().addClass('has-overflow'); + else $(entry.target).parent().removeClass('has-overflow'); + }); + }), + getIntersectionObserver(tablist) { + return new IntersectionObserver((entries) => { + entries.forEach( + (entry) => { + $(entry.target).data('isIntersecting', entry.isIntersecting); + }, + { + root: tablist, + threshold: 1, + } + ); + }); + }, + wrapAndAddIcons(tablist) { + const icons = Tabs.helpers.createIconset(); + // Wrap the tabs bar with a container, to allow introduction of + // tabs navigation arrow icons. + const $leftIcons = $('
').html( + Object.values(icons.left) + ); + const $rightIcons = $('
').html( + Object.values(icons.right) + ); + $(tablist) + .wrap('
') + .before($leftIcons) + .after($rightIcons); + $(icons.left.touch).click(function previousTab() { + tablist.dispatchEvent(new Event('previous-tab')); + }); + $(icons.right.touch).click(function nextTab() { + tablist.dispatchEvent(new Event('next-tab')); + }); + $(icons.left.scroll).click(function scrollLeft() { + tablist.dispatchEvent(new Event('scroll-left')); + }); + $(icons.right.scroll).click(function scrollRight() { + tablist.dispatchEvent(new Event('scroll-right')); + }); + }, + checkScrollability() { + // Function to update no-scroll-left and no-scroll-right + // classes for tabs bar wrapper. + if (!Tabs.helpers.hasLeftOverflow(this)) + $(this).parent().addClass('no-scroll-left'); + else $(this).parent().removeClass('no-scroll-left'); + if (!Tabs.helpers.hasRightOverflow(this)) + $(this).parent().addClass('no-scroll-right'); + else $(this).parent().removeClass('no-scroll-right'); + }, + getLeftScrollIndex(tablist, $tabs) { + const tablistWidth = tablist.clientWidth; + // Find the first visible tab. + let firstVisible = 0; + while ( + firstVisible < $tabs.length - 1 && + !$($tabs.get(firstVisible)).data('isIntersecting') + ) + firstVisible += 1; + // Calculate the tab to switch to. + let switchTo = firstVisible; + const end = tablist.scrollLeft; + while ( + switchTo >= 0 && + end - $tabs.get(switchTo).parentElement.offsetLeft < tablistWidth + ) + switchTo -= 1; + return switchTo + 1; + }, + getRightScrollIndex($tabs) { + // Calculate tab to switch to. + let switchTo = $tabs.length - 1; + while (switchTo > 0 && !$($tabs[switchTo]).data('isIntersecting')) switchTo -= 1; + return switchTo; + }, + addScrollListeners(tablist, $tabs) { + function scrollTo(i) { + tablist.scrollLeft = $tabs.get(i).offsetLeft - tablist.offsetLeft; + } + tablist.addEventListener('scroll-left', function scrollLeft() { + scrollTo(Tabs.getLeftScrollIndex(tablist, $tabs)); + }); + tablist.addEventListener('scroll-right', function scrollRight() { + scrollTo(Tabs.getRightScrollIndex($tabs)); + }); + $(tablist).scroll(Utils.debounce(Tabs.checkScrollability, 500)); + }, + addNavListeners(tablist, $tabs) { + let index = 0; + function activateCurrent() { + window.mui.tabs.activate($($tabs.get(index)).data('mui-controls')); + } + tablist.addEventListener('previous-tab', function previousTab() { + if (index > 0) index -= 1; + else index = $tabs.length - 1; + activateCurrent(); + }); + tablist.addEventListener('next-tab', function nextTab() { + if (index < $tabs.length - 1) index += 1; + else index = 0; + activateCurrent(); + }); + $tabs.each(function addTabListeners(tabIndex, tab) { + tab.addEventListener('mui.tabs.showend', function tabActivated(ev) { + index = tabIndex; + ev.srcElement.scrollIntoView(); + }); + }); + }, + enhanceARIA(tablist, $tabs) { + $tabs.on('keydown', function addArrowNav(event) { + const [LEFT, UP, RIGHT, DOWN] = [37, 38, 39, 40]; + const k = event.which || event.keyCode; + if (k >= LEFT && k <= DOWN) { + switch (k) { + case LEFT: + case UP: + tablist.dispatchEvent(new Event('previous-tab')); + break; + case RIGHT: + case DOWN: + tablist.dispatchEvent(new Event('next-tab')); + break; + default: + } + event.preventDefault(); + } + }); + $tabs.each(function addTabListeners(tabIndex, tab) { + tab.addEventListener('mui.tabs.showend', function tabActivated(ev) { + $(ev.srcElement).attr({ tabindex: 0, 'aria-selected': 'true' }).focus(); + }); + tab.addEventListener('mui.tabs.hideend', function tabDeactivated(ev) { + $(ev.srcElement).attr({ tabindex: -1, 'aria-selected': 'false' }).focus(); + }); + }); + }, + async processTablist(index, tablist) { + const $tablist = $(tablist); + const $tabs = $tablist.find('[role=tab]'); + const isMarkdown = $tablist.parent().hasClass('md-tabset'); + let visibilityObserver; + let $tablistContainer; + Tabs.addNavListeners(tablist, $tabs); + if (isMarkdown) { + $tablist.addClass('mui-tabs__bar'); + Tabs.addScrollListeners(tablist, $tabs); + Tabs.wrapAndAddIcons(tablist); + $tablistContainer = $tablist.parent(); + Tabs.overflowObserver.observe(tablist); + visibilityObserver = Tabs.getIntersectionObserver(tablist); + Tabs.checkScrollability.bind(tablist)(); + } + $tabs.each(function processTab(tabIndex, tab) { + if (isMarkdown) { + $(tab) + .attr('data-mui-toggle', 'tab') + .attr('data-mui-controls', $(tab).attr('aria-controls')); + visibilityObserver.observe(tab); + const $panel = $(`#${$(tab).attr('aria-controls')}`); + $panel.mouseenter( + $tablistContainer.addClass.bind($tablistContainer, 'has-panel-hover') + ); + $panel.mouseleave( + $tablistContainer.removeClass.bind($tablistContainer, 'has-panel-hover') + ); + } + }); + Tabs.enhanceARIA(tablist, $tabs); + $tablist.addClass('activated').removeClass('activating'); + }, + process($parentElement, $tablists) { + $parentElement.find('.md-tabset [role=tabpanel]').addClass('mui-tabs__pane'); + $parentElement.find('.md-tabset .md-tab-active').addClass('mui--is-active'); + $tablists.each(this.processTablist); + }, + async init(container) { + const $parentElement = $(container || 'body'); + const $tablists = $parentElement.find( + '[role=tablist]:not(.activating, .activated)' + ); + $tablists.addClass('activating'); + this.process($parentElement, $tablists); + }, + helpers: { + createIconset() { + return { + left: { + touch: this.createIcon('touch', 'left'), + scroll: this.createIcon('scroll', 'left'), + }, + right: { + touch: this.createIcon('touch', 'right'), + scroll: this.createIcon('scroll', 'right'), + }, + }; + }, + createIcon(mode, direction) { + return Utils.getFaiconHTML(`angle-${direction}`, 'body', true, [ + `tabs-nav-icon-${direction}`, + `js-tabs-${mode}`, + ]); + }, + hasOverflow(el) { + return el.scrollLeft + el.scrollWidth > el.clientWidth; + }, + hasLeftOverflow(el) { + return Boolean(el.scrollLeft); + }, + hasRightOverflow(el) { + return ( + el.scrollLeft + el.clientWidth + el.offsetLeft < + el.children[el.children.length - 1].offsetLeft + + el.children[el.children.length - 1].clientWidth + ); + }, + }, +}; + +export default Tabs; diff --git a/funnel/assets/js/utils/ticket_widget.js b/funnel/assets/js/utils/ticket_widget.js new file mode 100644 index 000000000..f791cd843 --- /dev/null +++ b/funnel/assets/js/utils/ticket_widget.js @@ -0,0 +1,158 @@ +import { AJAX_TIMEOUT, RETRY_INTERVAL } from '../constants'; +import Analytics from './analytics'; + +const Ticketing = { + init(tickets) { + if (tickets.boxofficeUrl) { + this.initBoxfficeWidget(tickets); + } + + this.initTicketModal(); + }, + + initBoxfficeWidget({ + boxofficeUrl, + widgetElem, + org, + itemCollectionId, + itemCollectionTitle, + }) { + let url; + + if (boxofficeUrl.slice(-1) === '/') { + url = `${boxofficeUrl}boxoffice.js`; + } else { + url = `${boxofficeUrl}/boxoffice.js`; + } + + $.get({ + url, + crossDomain: true, + timeout: AJAX_TIMEOUT, + retries: 5, + retryInterval: RETRY_INTERVAL, + + success(data) { + const boxofficeScript = document.createElement('script'); + boxofficeScript.innerHTML = data.script; + document.getElementsByTagName('body')[0].appendChild(boxofficeScript); + }, + + error(response) { + const ajaxLoad = this; + ajaxLoad.retries -= 1; + let errorMsg; + + if (response.readyState === 4) { + errorMsg = window.gettext( + 'The server is experiencing difficulties. Try again in a few minutes' + ); + $(widgetElem).html(errorMsg); + } else if (response.readyState === 0) { + if (ajaxLoad.retries < 0) { + if (!navigator.onLine) { + errorMsg = window.gettext('This device has no internet connection'); + } else { + errorMsg = window.gettext( + 'Unable to connect. If this device is behind a firewall or using any script blocking extension (like Privacy Badger), please ensure your browser can load boxoffice.hasgeek.com, api.razorpay.com and checkout.razorpay.com' + ); + } + + $(widgetElem).html(errorMsg); + } else { + setTimeout(() => { + $.get(ajaxLoad); + }, ajaxLoad.retryInterval); + } + } + }, + }); + window.addEventListener( + 'onBoxofficeInit', + () => { + window.Boxoffice.init({ + org, + itemCollection: itemCollectionId, + paymentDesc: itemCollectionTitle, + }); + }, + false + ); + $(document).on('boxofficeTicketingEvents', (event, userAction, label, value) => { + Analytics.sendToGA('ticketing', userAction, label, value); + }); + $(document).on( + 'boxofficeShowPriceEvent', + (event, prices, currency, quantityAvailable) => { + let price; + let maxPrice; + const isTicketAvailable = + quantityAvailable.length > 0 + ? Math.min.apply(null, quantityAvailable.filter(Boolean)) + : 0; + const minPrice = prices.length > 0 ? Math.min(...prices) : -1; + if (!isTicketAvailable || minPrice < 0) { + $('.js-tickets-available').addClass('mui--hide'); + $('.js-tickets-not-available').removeClass('mui--hide'); + $('.js-open-ticket-widget') + .addClass('mui--is-disabled') + .prop('disabled', true); + } else { + price = `${currency}${minPrice}`; + if (prices.length > 1) { + maxPrice = Math.max(...prices); + price = `${currency}${minPrice} - ${currency}${maxPrice}`; + } + $('.js-ticket-price').text(price); + } + } + ); + }, + + initTicketModal() { + this.urlHash = '#tickets'; + if (window.location.hash.indexOf(this.urlHash) > -1) { + this.openTicketModal(); + } + + $('.js-open-ticket-widget').click((event) => { + event.preventDefault(); + this.openTicketModal(); + }); + + $('body').on('click', '#close-ticket-widget', (event) => { + event.preventDefault(); + this.hideTicketModal(); + }); + + $(window).on('popstate', () => { + this.hideTicketModal(); + }); + }, + + openTicketModal() { + window.history.pushState( + { + openModal: true, + }, + '', + this.urlHash + ); + $('.header').addClass('header--lowzindex'); + $('.tickets-wrapper__modal').addClass('tickets-wrapper__modal--show'); + $('.tickets-wrapper__modal').show(); + }, + + hideTicketModal() { + if ($('.tickets-wrapper__modal').hasClass('tickets-wrapper__modal--show')) { + $('.header').removeClass('header--lowzindex'); + $('.tickets-wrapper__modal').removeClass('tickets-wrapper__modal--show'); + $('.tickets-wrapper__modal').hide(); + if (window.history.state.openModal) { + window.history.back(); + } + } + }, +}; + +export default Ticketing; diff --git a/funnel/assets/js/utils/timezone.js b/funnel/assets/js/utils/timezone.js new file mode 100644 index 000000000..500be97fc --- /dev/null +++ b/funnel/assets/js/utils/timezone.js @@ -0,0 +1,15 @@ +import 'jquery.cookie'; + +// Detect timezone for login +async function setTimezoneCookie() { + if (!$.cookie('timezone')) { + let timezone = Intl.DateTimeFormat()?.resolvedOptions()?.timeZone; + if (!timezone) { + const { default: jstz } = await import('jstz'); + timezone = jstz.determine().name(); + } + $.cookie('timezone', timezone, { path: '/' }); + } +} + +export default setTimezoneCookie; diff --git a/funnel/assets/js/utils/translations.js b/funnel/assets/js/utils/translations.js index c7d3c7a3e..28ec47b1c 100644 --- a/funnel/assets/js/utils/translations.js +++ b/funnel/assets/js/utils/translations.js @@ -1,11 +1,15 @@ import Gettext from './gettext'; +const AVAILABLE_LANGUAGES = { + en: 'en_IN', + hi: 'hi_IN', +}; + function getLocale() { // Instantiate i18n with browser context const { lang } = document.documentElement; const langShortForm = lang.substring(0, 2); - window.Hasgeek.Config.locale = - window.Hasgeek.Config.availableLanguages[langShortForm]; + window.Hasgeek.Config.locale = AVAILABLE_LANGUAGES[langShortForm]; return window.Hasgeek.Config.locale; } diff --git a/funnel/assets/js/utils/update_parsley_config.js b/funnel/assets/js/utils/update_parsley_config.js new file mode 100644 index 000000000..8fdb4dd17 --- /dev/null +++ b/funnel/assets/js/utils/update_parsley_config.js @@ -0,0 +1,49 @@ +function updateParsleyConfig() { + // Override Parsley.js's default messages after the page loads. + // Our versions don't use full stops after phrases. + window.ParsleyConfig = { + errorsWrapper: '
', + errorTemplate: '

', + errorClass: 'has-error', + classHandler(ParsleyField) { + return ParsleyField.$element.closest('.mui-form__fields'); + }, + errorsContainer(ParsleyField) { + return ParsleyField.$element.closest('.mui-form__controls'); + }, + i18n: { + en: {}, + }, + }; + + window.ParsleyConfig.i18n.en = $.extend(window.ParsleyConfig.i18n.en || {}, { + defaultMessage: 'This value seems to be invalid', + notblank: 'This value should not be blank', + required: 'This value is required', + pattern: 'This value seems to be invalid', + min: 'This value should be greater than or equal to %s', + max: 'This value should be lower than or equal to %s', + range: 'This value should be between %s and %s', + minlength: 'This value is too short. It should have %s characters or more', + maxlength: 'This value is too long. It should have %s characters or fewer', + length: 'This value should be between %s and %s characters long', + mincheck: 'You must select at least %s choices', + maxcheck: 'You must select %s choices or fewer', + check: 'You must select between %s and %s choices', + equalto: 'This value should be the same', + }); + + window.ParsleyConfig.i18n.en.type = $.extend( + window.ParsleyConfig.i18n.en.type || {}, + { + email: 'This value should be a valid email', + url: 'This value should be a valid url', + number: 'This value should be a valid number', + integer: 'This value should be a valid integer', + digits: 'This value should be digits', + alphanum: 'This value should be alphanumeric', + } + ); +} + +export default updateParsleyConfig; diff --git a/funnel/assets/js/utils/vegaembed.js b/funnel/assets/js/utils/vegaembed.js index b23077e13..c409a708a 100644 --- a/funnel/assets/js/utils/vegaembed.js +++ b/funnel/assets/js/utils/vegaembed.js @@ -1,8 +1,7 @@ async function addVegaSupport(container) { const parentElement = $(container || 'body'); if ( - parentElement.find('.md-embed-vega-lite:not(.activating):not(.activated)').length > - 0 + parentElement.find('.md-embed-vega-lite:not(.activating, .activated)').length > 0 ) { const { default: embed } = await import('vega-embed'); const options = { diff --git a/funnel/assets/js/utils/vue_util.js b/funnel/assets/js/utils/vue_util.js index 23ada85d9..f4078556c 100644 --- a/funnel/assets/js/utils/vue_util.js +++ b/funnel/assets/js/utils/vue_util.js @@ -1,9 +1,11 @@ import Vue from 'vue/dist/vue.min'; import Utils from './helper'; +import WebShare from './webshare'; +import { USER_AVATAR_IMG_SIZE } from '../constants'; export const userAvatarUI = Vue.component('useravatar', { template: - '
{{ getInitials(user.fullname) }}
{{ getInitials(user.fullname) }}', + '
{{ getInitials(user.fullname) }}
{{ getInitials(user.fullname) }}', props: { user: Object, addprofilelink: { @@ -16,15 +18,15 @@ export const userAvatarUI = Vue.component('useravatar', { }, }, methods: { - getInitials: window.Hasgeek.Utils.getInitials, - getAvatarColour: window.Hasgeek.Utils.getAvatarColour, + getInitials: Utils.getInitials, + getAvatarColour: Utils.getAvatarColour, }, computed: { imgsize() { - return window.Hasgeek.Config.userAvatarImgSize[this.size]; + return USER_AVATAR_IMG_SIZE[this.size]; }, imgurl() { - return `${this.user.avatar}?size=${encodeURIComponent(this.imgsize)}`; + return `${this.user.logo_url}?size=${encodeURIComponent(this.imgsize)}`; }, }, }); @@ -94,6 +96,6 @@ export const shareDropdown = Vue.component('sharedropdown', { }, }, mounted() { - Utils.enableWebShare(); + WebShare.enableWebShare(); }, }); diff --git a/funnel/assets/js/utils/webshare.js b/funnel/assets/js/utils/webshare.js new file mode 100644 index 000000000..c989b2f95 --- /dev/null +++ b/funnel/assets/js/utils/webshare.js @@ -0,0 +1,91 @@ +/* global gettext */ +import toastr from 'toastr'; +import Utils from './helper'; + +const WebShare = { + addWebShare() { + if (navigator.share) { + $('.project-links').hide(); + $('.hg-link-btn').removeClass('mui--hide'); + + const mobileShare = (title, url, text) => { + navigator.share({ + title, + url, + text, + }); + }; + + $('body').on('click', '.hg-link-btn', function clickWebShare(event) { + event.preventDefault(); + const linkElem = this; + let url = + $(linkElem).data('url') || + (document.querySelector('link[rel=canonical]') && + document.querySelector('link[rel=canonical]').href) || + window.location.href; + const title = $(this).data('title') || document.title; + const text = $(this).data('text') || ''; + if ($(linkElem).attr('data-shortlink')) { + mobileShare(title, url, text); + } else { + Utils.fetchShortUrl(url) + .then((shortlink) => { + url = shortlink; + $(linkElem).attr('data-shortlink', true); + }) + .finally(() => { + mobileShare(title, url, text); + }); + } + }); + } else { + $('body').on('click', '.js-copy-link', function clickCopyLink(event) { + event.preventDefault(); + const linkElem = this; + const copyLink = () => { + const url = $(linkElem).find('.js-copy-url').first().text(); + if (navigator.clipboard) { + navigator.clipboard.writeText(url).then( + () => toastr.success(gettext('Link copied')), + () => toastr.success(gettext('Could not copy link')) + ); + } else { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents($(linkElem).find('.js-copy-url')[0]); + selection.removeAllRanges(); + selection.addRange(range); + if (document.execCommand('copy')) { + toastr.success(gettext('Link copied')); + } else { + toastr.error(gettext('Could not copy link')); + } + selection.removeAllRanges(); + } + }; + if ($(linkElem).attr('data-shortlink')) { + copyLink(); + } else { + Utils.fetchShortUrl($(linkElem).find('.js-copy-url').first().html()) + .then((shortlink) => { + $(linkElem).find('.js-copy-url').text(shortlink); + $(linkElem).attr('data-shortlink', true); + copyLink(); + }) + .catch((errMsg) => { + toastr.error(errMsg); + }); + } + }); + } + }, + enableWebShare() { + if (navigator.share) { + $('.project-links').hide(); + $('.hg-link-btn').removeClass('mui--hide'); + } + }, +}; + +export default WebShare; diff --git a/funnel/assets/sass/app.scss b/funnel/assets/sass/app.scss index 5a94594b5..d576e4a32 100644 --- a/funnel/assets/sass/app.scss +++ b/funnel/assets/sass/app.scss @@ -27,7 +27,6 @@ @import 'components/chip'; @import 'components/alert'; @import 'components/thumbnail'; -@import 'components/modal'; @import 'components/tabs'; @import 'components/responsive-table'; @import 'components/card'; @@ -41,3 +40,7 @@ @import 'base/base'; @import 'base/layout'; @import 'base/utils'; + +@import 'toastr'; +@import 'jquery-modal'; +@import 'components/modal'; diff --git a/funnel/assets/sass/base/_base.scss b/funnel/assets/sass/base/_base.scss index ae090b7dd..089c0c25a 100644 --- a/funnel/assets/sass/base/_base.scss +++ b/funnel/assets/sass/base/_base.scss @@ -21,7 +21,7 @@ h3, h4, h5, h6 { - margin-top: $mui-grid-padding/2; + margin-top: $mui-grid-padding * 0.5; margin-bottom: 14px; } @@ -125,7 +125,7 @@ samp { blockquote { border-left: 3px solid $mui-text-accent; margin: 0; - padding: 0 0 0 $mui-grid-padding/2; + padding: 0 0 0 $mui-grid-padding * 0.5; margin-bottom: 14px; } diff --git a/funnel/assets/sass/base/_formvariable.scss b/funnel/assets/sass/base/_formvariable.scss new file mode 100644 index 000000000..a8f76df38 --- /dev/null +++ b/funnel/assets/sass/base/_formvariable.scss @@ -0,0 +1,10 @@ +$mui-form-group-margin-bottom: $mui-grid-padding * 0.5; +$mui-input-height: 32px !default; +$mui-input-border-color: $mui-text-accent !default; +$mui-input-border-color-focus: $mui-text-hyperlink !default; +$mui-input-bg-color: transparent !default; +$mui-input-font-color: $mui-text-dark; +$mui-cursor-disabled: not-allowed; +$mui-input-bg-color-disabled: transparent; +$mui-text-dark-hint: rgba(#000, 0.38); +$mui-text-dark-secondary: rgba(#000, 0.54); diff --git a/funnel/assets/sass/base/_layout.scss b/funnel/assets/sass/base/_layout.scss index b85911068..22b940b75 100644 --- a/funnel/assets/sass/base/_layout.scss +++ b/funnel/assets/sass/base/_layout.scss @@ -34,7 +34,7 @@ padding-top: $mui-grid-padding; padding-bottom: $mui-grid-padding; border-bottom: 1px solid $mui-divider-color; - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } .page-content--mob-nav { @@ -258,7 +258,7 @@ .link-icon { font-size: 12px; font-weight: normal; - padding: 0 $mui-grid-padding/2 $mui-grid-padding/2; + padding: 0 $mui-grid-padding * 0.5 $mui-grid-padding * 0.5; line-height: 1; text-transform: capitalize; text-decoration: none !important; diff --git a/funnel/assets/sass/base/_mui_form_essentials.scss b/funnel/assets/sass/base/_mui_form_essentials.scss new file mode 100644 index 000000000..23d53968a --- /dev/null +++ b/funnel/assets/sass/base/_mui_form_essentials.scss @@ -0,0 +1,10 @@ +@import '../base/variable'; +@import '../base/formvariable'; +@import '../mui/mixins/forms'; +@import '../mui/colors_custom'; + +.mui--z1 { + box-shadow: + 0 1px 3px rgba(mui-color('grey'), 0.12), + 0 1px 2px rgba(mui-color('grey'), 0.24); +} diff --git a/funnel/assets/sass/base/_utils.scss b/funnel/assets/sass/base/_utils.scss index 69ce44328..ad53ee2f1 100644 --- a/funnel/assets/sass/base/_utils.scss +++ b/funnel/assets/sass/base/_utils.scss @@ -11,19 +11,19 @@ } .margin-bottom { - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } .margin-top { - margin-top: $mui-grid-padding/2; + margin-top: $mui-grid-padding * 0.5; } .margin-right { - margin-right: $mui-grid-padding/2; + margin-right: $mui-grid-padding * 0.5; } .margin-left { - margin-left: $mui-grid-padding/2; + margin-left: $mui-grid-padding * 0.5; } .margin-auto { @@ -71,7 +71,7 @@ } .mui-divider--custom { - margin: $mui-grid-padding/2 0 $mui-grid-padding * 1.5; + margin: $mui-grid-padding * 0.5 0 $mui-grid-padding * 1.5; } .separator { @@ -98,6 +98,10 @@ align-items: flex-start; } +.flex-wrapper--end { + align-items: flex-end; +} + .flex-wrapper--space-between { justify-content: space-between; } @@ -194,6 +198,10 @@ color: $mui-primary-color; } +.primary-color-lighter-txt { + color: rgba($mui-primary-color-lighter, 0.75); +} + .secondary-color-txt { color: $mui-accent-color; } @@ -235,6 +243,17 @@ width: 100%; } +.img-rounded-border { + border-radius: 16px; +} + +.img-fit { + position: absolute; + object-fit: fill; + width: 100%; + height: 100%; +} + // ============================================================================ // Overlay // ============================================================================ diff --git a/funnel/assets/sass/base/_variable.scss b/funnel/assets/sass/base/_variable.scss index 9a492e584..c69482172 100644 --- a/funnel/assets/sass/base/_variable.scss +++ b/funnel/assets/sass/base/_variable.scss @@ -38,3 +38,10 @@ $xFormLabelLineHeight: 15px; $mui-base-font-size: 14px; $mui-base-line-height: 1.5; $mui-box-shadow-grey: rgba(158, 158, 158, 0.12); + +// MUI Tabs +$mui-tab-font-color: $mui-text-accent; +$mui-tab-font-color-active: $mui-primary-color; +$mui-tab-border-color-active: $mui-primary-color; +$mui-tab-font-color-hover: $mui-neutral-color; +$mui-tab-border-color-hover: $mui-neutral-color; diff --git a/funnel/assets/sass/components/_button.scss b/funnel/assets/sass/components/_button.scss index 1bef6a0a6..064ff09bd 100644 --- a/funnel/assets/sass/components/_button.scss +++ b/funnel/assets/sass/components/_button.scss @@ -94,6 +94,16 @@ box-shadow: none; } +.mui-btn--accent.mui--is-disabled, +.mui-btn--accent.mui--is-disabled:hover, +.mui-btn--accent.mui--is-disabled:active, +.mui-btn--accent.mui--is-disabled:focus .mui-btn--accent.mui--is-disabled:active:hover { + background: $mui-bg-color-primary; + color: $mui-primary-color; + border: 1px solid $mui-primary-color; + box-shadow: none; +} + .mui-btn--accent.mui-btn--flat { border: none !important; } @@ -198,10 +208,10 @@ .nav-btn-wrapper, .prev-next-btn-wrapper { .btn-margin-right { - margin-right: $mui-grid-padding/4; + margin-right: $mui-grid-padding * 0.25; } .btn-margin-left { - margin-left: $mui-grid-padding/4; + margin-left: $mui-grid-padding * 0.25; } } diff --git a/funnel/assets/sass/components/_card.scss b/funnel/assets/sass/components/_card.scss index b38f71b28..879bf8f80 100644 --- a/funnel/assets/sass/components/_card.scss +++ b/funnel/assets/sass/components/_card.scss @@ -13,7 +13,7 @@ } .card__title__heading { - margin: $mui-grid-padding/4 auto; + margin: $mui-grid-padding * 0.25 auto; } .card__header { @@ -23,16 +23,22 @@ .card__header__title { width: 80%; + margin-top: 8px; + margin-bottom: 8px; } + .card__header--danger { + padding: $mui-grid-padding; + background: $mui-danger-color; + } .card__body { - padding: $mui-grid-padding/2 $mui-grid-padding; + padding: $mui-grid-padding * 0.5 $mui-grid-padding; word-break: break-word; } .card__footer { clear: both; - padding: 0 $mui-grid-padding/2; + padding: 0 $mui-grid-padding * 0.5; } .mui-btn + .mui-btn { @@ -53,6 +59,10 @@ .card--shaped { border-radius: 16px 16px 0 16px; + .card__image-wrapper { + border-radius: 16px 16px 0 0; + overflow: hidden; + } } .clickable-card:focus, @@ -102,7 +112,7 @@ } .card__calendar { - padding: $mui-grid-padding/2 0 $mui-grid-padding-double; + padding: $mui-grid-padding * 0.5 0 $mui-grid-padding-double; margin-left: -$mui-grid-padding; margin-right: -$mui-grid-padding; @@ -364,18 +374,13 @@ padding: 0 $mui-grid-padding $mui-grid-padding; position: relative; - .card__body__location { - float: left; - max-width: 90%; - } - .card__body__bookmark { float: right; - margin: $mui-grid-padding/4 0 0; + margin: $mui-grid-padding * 0.25 0 0; } .card__body__title { - margin: 0 0 $mui-grid-padding/2; + margin: 0 0 $mui-grid-padding * 0.5; } .card__body__title--smaller { @@ -387,10 +392,6 @@ max-width: calc(100% - 20px); } - .card__body__location { - margin: 0 0 $mui-grid-padding/2; - float: left; - } .card__body__divider { height: 4px; background-color: $mui-bg-color-primary-dark; @@ -404,7 +405,9 @@ } .card--upcoming:hover { - box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); + box-shadow: + 0 14px 28px rgba(0, 0, 0, 0.25), + 0 10px 10px rgba(0, 0, 0, 0.22); } @media (min-width: 1200px) { @@ -423,7 +426,7 @@ .card__image-wrapper--default:after { content: ''; position: absolute; - z-index: 2; + z-index: 1; top: 0; left: 0; background-color: $mui-primary-color; @@ -484,7 +487,7 @@ } .profile-card__btn-wrapper { margin-top: auto; - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } } diff --git a/funnel/assets/sass/components/_chip.scss b/funnel/assets/sass/components/_chip.scss index 949eaf11a..4d80fb1bf 100644 --- a/funnel/assets/sass/components/_chip.scss +++ b/funnel/assets/sass/components/_chip.scss @@ -1,5 +1,5 @@ .chip { - padding: 0 8px 2px; + padding: 2px 8px 2px; border-radius: 16px; display: inline-block; white-space: nowrap; @@ -34,6 +34,12 @@ color: mui-color('white'); } +.chip--bg-success { + background-color: $mui-success-color; + border-color: transparentize($mui-text-success, 0.8); + color: $mui-text-success; +} + .chip + .chip { margin-left: $mui-btn-spacing-horizontal; } diff --git a/funnel/assets/sass/components/_collapsible.scss b/funnel/assets/sass/components/_collapsible.scss index c956144d9..91c3e29f4 100644 --- a/funnel/assets/sass/components/_collapsible.scss +++ b/funnel/assets/sass/components/_collapsible.scss @@ -16,7 +16,7 @@ } .collapsible__header--inner { - padding: $mui-grid-padding/2; + padding: $mui-grid-padding * 0.5; background: $mui-bg-color-accent; } diff --git a/funnel/assets/sass/components/_draggablebox.scss b/funnel/assets/sass/components/_draggablebox.scss index 89fa3d69f..3f0162022 100644 --- a/funnel/assets/sass/components/_draggablebox.scss +++ b/funnel/assets/sass/components/_draggablebox.scss @@ -81,9 +81,9 @@ } .drag-box__body { - padding-bottom: $mui-grid-padding/2; + padding-bottom: $mui-grid-padding * 0.5; .drag-box__body__options { - margin-right: $mui-grid-padding/4; + margin-right: $mui-grid-padding * 0.25; } } @@ -91,7 +91,7 @@ display: flex; float: right; .drag-box__action-btn { - padding-right: $mui-grid-padding/4; + padding-right: $mui-grid-padding * 0.25; } } } diff --git a/funnel/assets/sass/components/_header.scss b/funnel/assets/sass/components/_header.scss index ead55bcfa..02a476f6f 100644 --- a/funnel/assets/sass/components/_header.scss +++ b/funnel/assets/sass/components/_header.scss @@ -45,7 +45,8 @@ bottom: $mui-header-height; width: 100%; left: 0; - box-shadow: 0 1px 3px rgba(158, 158, 158, 0.12), + box-shadow: + 0 1px 3px rgba(158, 158, 158, 0.12), 0 1px 2px rgba(158, 158, 158, 0.24); display: none; @@ -214,7 +215,7 @@ } } li .header__dropdown__item { - padding: $mui-grid-padding/2 $mui-grid-padding $mui-grid-padding/2; + padding: $mui-grid-padding * 0.5 $mui-grid-padding $mui-grid-padding * 0.5; } li .header__dropdown__item--morepadding { padding-left: 24px; @@ -264,7 +265,7 @@ align-items: end; .user__box__banner { width: 60px; - margin-right: $mui-grid-padding/2; + margin-right: $mui-grid-padding * 0.5; .user__box__banner__wrapper { position: relative; width: 100%; @@ -279,15 +280,15 @@ } .user__box__banner__wrapper__icon { position: absolute; - bottom: $mui-grid-padding/4; - right: $mui-grid-padding/4; + bottom: $mui-grid-padding * 0.25; + right: $mui-grid-padding * 0.25; } } } .user__box__header.comment__details { width: calc(100% - 70px); .user__box__fullname { - margin: 0 0 $mui-grid-padding/4; + margin: 0 0 $mui-grid-padding * 0.25; line-height: 1; display: flex; width: 100%; @@ -302,7 +303,7 @@ } .comment__details__title { width: calc(100% - 88px); - margin-right: $mui-grid-padding/2; + margin-right: $mui-grid-padding * 0.5; } .comment__details__title--long { width: 100%; @@ -312,7 +313,7 @@ } } .user__box__subcontent { - margin: 0 0 $mui-grid-padding/4; + margin: 0 0 $mui-grid-padding * 0.25; line-height: 1; display: flex; width: 100%; @@ -327,7 +328,7 @@ } .comment__details__user--truncated { width: calc(100% - 24px); - margin-right: $mui-grid-padding/2; + margin-right: $mui-grid-padding * 0.5; } } } @@ -489,7 +490,9 @@ .comments-sidebar { width: 360px; max-height: 90vh; - box-shadow: 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: + 0 0 2px rgba(0, 0, 0, 0.12), + 0 2px 2px rgba(0, 0, 0, 0.2); border-radius: 16px 0 16px 16px; position: absolute; top: -100vh; @@ -499,7 +502,7 @@ bottom: auto; } .comments-sidebar { - padding: $mui-grid-padding/2 0; + padding: $mui-grid-padding * 0.5 0; } } } @@ -528,7 +531,7 @@ align-items: center; .header__site-title__home__logo, .header__nav-links__icon { - margin: 0 $mui-grid-padding/4 0 0; + margin: 0 $mui-grid-padding * 0.25 0 0; } .header__nav-links__text { font-size: 14px; @@ -619,7 +622,8 @@ .mobile-nav__icon { display: inline-block; - padding: $mui-grid-padding $mui-grid-padding/2 $mui-grid-padding $mui-grid-padding; + padding: $mui-grid-padding $mui-grid-padding * 0.5 $mui-grid-padding + $mui-grid-padding; line-height: 1; } .mobile-nav__icon--right { diff --git a/funnel/assets/sass/components/_list.scss b/funnel/assets/sass/components/_list.scss index 887a3338b..58aabc962 100644 --- a/funnel/assets/sass/components/_list.scss +++ b/funnel/assets/sass/components/_list.scss @@ -23,7 +23,7 @@ .list--border--padding { li { - padding: $mui-grid-padding/2 $mui-grid-padding; + padding: $mui-grid-padding * 0.5 $mui-grid-padding; } } diff --git a/funnel/assets/sass/components/_markdown.scss b/funnel/assets/sass/components/_markdown.scss index f82ea65d8..4184faf36 100644 --- a/funnel/assets/sass/components/_markdown.scss +++ b/funnel/assets/sass/components/_markdown.scss @@ -1,4 +1,6 @@ @import 'table'; +@import 'node_modules/prismjs/themes/prism'; +@import 'node_modules/prismjs/plugins/match-braces/prism-match-braces'; .markdown { overflow-wrap: break-word; @@ -13,16 +15,7 @@ font-weight: 700; a { color: $mui-text-dark; - display: none; - } - &:hover a { - display: inline; - } - @media (any-pointer: coarse) { - a { - display: inline; - color: $mui-text-accent; - } + text-decoration: none; } } diff --git a/funnel/assets/sass/components/_menu.scss b/funnel/assets/sass/components/_menu.scss index 76b14111e..548d50bd3 100644 --- a/funnel/assets/sass/components/_menu.scss +++ b/funnel/assets/sass/components/_menu.scss @@ -2,9 +2,11 @@ border-radius: 0 16px 16px 16px; padding: 0; overflow: hidden; - box-shadow: 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: + 0 0 2px rgba(0, 0, 0, 0.12), + 0 2px 2px rgba(0, 0, 0, 0.2); > li > a { - padding: $mui-grid-padding/2 $mui-grid-padding; + padding: $mui-grid-padding * 0.5 $mui-grid-padding; // hover & focus state &:hover, diff --git a/funnel/assets/sass/components/_modal.scss b/funnel/assets/sass/components/_modal.scss index 9bceed198..ac804a51b 100644 --- a/funnel/assets/sass/components/_modal.scss +++ b/funnel/assets/sass/components/_modal.scss @@ -34,12 +34,6 @@ margin-top: $mui-grid-padding; } } - .modal--form { - width: 100%; - border-radius: 0; - overflow: auto; - min-height: 100%; - } .modal--fullscreen { width: 100%; height: 100%; @@ -56,7 +50,6 @@ max-width: 500px; width: 90%; padding: $mui-grid-padding; - min-height: auto; border-radius: 8px; } .modal--fullscreen { diff --git a/funnel/assets/sass/components/_profileavatar.scss b/funnel/assets/sass/components/_profileavatar.scss index 57960c426..51f362f4e 100644 --- a/funnel/assets/sass/components/_profileavatar.scss +++ b/funnel/assets/sass/components/_profileavatar.scss @@ -9,6 +9,7 @@ align-items: center; justify-content: center; overflow: hidden; + box-sizing: border-box; img { width: 100%; height: 100%; diff --git a/funnel/assets/sass/components/_proposal-card.scss b/funnel/assets/sass/components/_proposal-card.scss index 6b7b90652..9df7a1e01 100644 --- a/funnel/assets/sass/components/_proposal-card.scss +++ b/funnel/assets/sass/components/_proposal-card.scss @@ -3,7 +3,7 @@ .proposal-card__body__inner { .proposal-card__body__inner__headline { .proposal-card__body__inner__headline__content { - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } .proposal-card__body__inner__headline__info__icon { position: relative; @@ -15,8 +15,8 @@ .proposal-card__body__inner__details__video { position: relative; width: 100%; - margin-right: $mui-grid-padding/2; - margin-bottom: $mui-grid-padding/2; + margin-right: $mui-grid-padding * 0.5; + margin-bottom: $mui-grid-padding * 0.5; .proposal-card__body__inner__details__video__thumbnail { position: relative; width: 100%; @@ -62,7 +62,7 @@ .proposal-card__body__inner__details__txt { position: absolute; left: 0; - bottom: $mui-grid-padding/2; + bottom: $mui-grid-padding * 0.5; } } } diff --git a/funnel/assets/sass/components/_responsive-table.scss b/funnel/assets/sass/components/_responsive-table.scss index b93b16b23..887622b91 100644 --- a/funnel/assets/sass/components/_responsive-table.scss +++ b/funnel/assets/sass/components/_responsive-table.scss @@ -28,11 +28,11 @@ padding: 10px 0; &:first-child { - padding-top: $mui-grid-padding/2; + padding-top: $mui-grid-padding * 0.5; } &:last-child { - padding-bottom: $mui-grid-padding/2; + padding-bottom: $mui-grid-padding * 0.5; } &:before { diff --git a/funnel/assets/sass/components/_search-field.scss b/funnel/assets/sass/components/_search-field.scss index 690935c63..702671e2d 100644 --- a/funnel/assets/sass/components/_search-field.scss +++ b/funnel/assets/sass/components/_search-field.scss @@ -11,8 +11,8 @@ .search { width: 100%; - margin: 0 0 $mui-grid-padding/2; - padding: 0 $mui-grid-padding/2; + margin: 0 0 $mui-grid-padding * 0.5; + padding: 0 $mui-grid-padding * 0.5; border: 1px solid $mui-divider-color; border-radius: 2px; position: relative; diff --git a/funnel/assets/sass/components/_subnavbar.scss b/funnel/assets/sass/components/_subnavbar.scss index a17bf9595..4f221e422 100644 --- a/funnel/assets/sass/components/_subnavbar.scss +++ b/funnel/assets/sass/components/_subnavbar.scss @@ -22,7 +22,9 @@ right: 0; z-index: 1000; border-bottom: none; - box-shadow: 0 1px 3px rgba(158, 158, 158, 0.12), 0 1px 2px rgba(158, 158, 158, 0.24); + box-shadow: + 0 1px 3px rgba(158, 158, 158, 0.12), + 0 1px 2px rgba(158, 158, 158, 0.24); } .sub-navbar__item:nth-child(2) { diff --git a/funnel/assets/sass/components/_switch.scss b/funnel/assets/sass/components/_switch.scss index 8e1e82da3..fe0957417 100644 --- a/funnel/assets/sass/components/_switch.scss +++ b/funnel/assets/sass/components/_switch.scss @@ -34,7 +34,9 @@ height: 20px; background-color: #fafafa; border-radius: 50%; - box-shadow: 0 2px 1px -1px rgba(0, 0, 0, 0.2), 0 1px 1px 0 rgba(0, 0, 0, 0.14), + box-shadow: + 0 2px 1px -1px rgba(0, 0, 0, 0.2), + 0 1px 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.12); } .switch-input:checked + .switch-label:before { diff --git a/funnel/assets/sass/components/_tabs.scss b/funnel/assets/sass/components/_tabs.scss index 9c7b5f92e..82aa73c03 100644 --- a/funnel/assets/sass/components/_tabs.scss +++ b/funnel/assets/sass/components/_tabs.scss @@ -1,3 +1,138 @@ +.mui-tabs__bar:not(.mui-tabs__bar--pills) { + border-bottom: 1px solid $mui-tab-font-color; + scroll-behavior: smooth; + > li { + transition: transform 150ms ease; + > a { + text-transform: capitalize; + height: auto; + line-height: 21px; + padding: 6px 0 2px; + text-decoration: none; + cursor: pointer; + &:hover { + border-bottom: 2px solid $mui-tab-border-color-hover; + } + } + &:not(.mui--is-active) > a:hover { + color: $mui-tab-font-color-hover; + } + &:not(:last-child) { + margin-right: 12px; + } + &.mui--is-active > a { + border-bottom: none; + } + } + + &::-webkit-scrollbar { + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox */ + position: relative; + display: none; /* Safari and Chrome */ + } +} + +.md-tablist-wrapper { + display: flex; + margin: -$mui-grid-padding * 0.5; + align-items: center; + + > .mui-tabs__bar:not(.mui-tabs__bar--pills) { + display: inline-block; + width: calc(100% - 28px); + } + + > [class*='tabs-nav-icons-'] { + width: 14px; + height: 16px; + display: inline-flex; + visibility: hidden; + + > [class*='tabs-nav-icon-'] { + display: none; + height: 100%; + width: auto; + cursor: pointer; + color: $mui-tab-font-color; + + &:hover { + color: $mui-tab-font-color-hover; + } + } + } + + > .tabs-nav-icons-left > [class*='tabs-nav-icon-']:hover { + padding-right: 3px; + } + + > .tabs-nav-icons-right > [class*='tabs-nav-icon-']:hover { + padding-left: 3px; + } + + &:hover > [class*='tabs-nav-icons-'] { + visibility: visible; + } + + &.has-overflow > [class*='tabs-nav-icons-'] > [class*='tabs-nav-icon-'] { + &.js-tabs-scroll { + display: inline; + } + + &.js-tabs-touch { + display: none; + } + + @media (any-pointer: coarse) { + &.js-tabs-scroll { + display: none; + } + + &.js-tabs-touch { + display: inline; + } + } + } + &.no-scroll-left > .tabs-nav-icons-left, + &.no-scroll-right > .tabs-nav-icons-right { + .js-tabs-scroll { + visibility: hidden !important; + } + } + + // Uncomment to enable hiding of nav on + // non-touch devices, when first / last tab + // is activated. + + // &.tabs-active-first > .tabs-nav-icons-left, + // &.tabs-active-last > .tabs-nav-icons-right { + // .js-tabs-touch { + // visibility: hidden !important; + // } + // } + + @media (any-pointer: coarse) { + > [class*='tabs-nav-icons-'] { + visibility: visible; + } + } + + &.has-panel-hover > [class*='tabs-nav-icons-'] { + visibility: visible; + } +} + +.mui-tabs__pane { + .mui-tabs__bar--wrapper { + margin: 0 -5px; + } + + .mui-tabs__pane { + padding-left: 9px; + padding-right: 9px; + } +} + .tab-container { display: flex; padding: 0; @@ -6,7 +141,7 @@ .tab-container__tab { flex: 1 0 0; - padding: $mui-grid-padding/2; + padding: $mui-grid-padding * 0.5; opacity: 0.4; text-align: center; border-bottom: 2px solid transparent; @@ -34,7 +169,7 @@ .mui-tabs__bar--pills { li { border-radius: 16px; - margin-right: $mui-grid-padding/2; + margin-right: $mui-grid-padding * 0.5; color: $mui-text-light; border: 1px solid $mui-bg-color-dark; background: $mui-bg-color-primary; @@ -42,7 +177,7 @@ li a { height: auto; line-height: inherit; - padding: $mui-grid-padding/4 $mui-grid-padding; + padding: $mui-grid-padding * 0.25 $mui-grid-padding; cursor: pointer; color: $mui-text-light; text-decoration: none !important; @@ -70,12 +205,12 @@ width: 100%; overflow: auto; align-items: center; - margin: $mui-grid-padding/2 0; + margin: $mui-grid-padding * 0.5 0; -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ .tabs__item { - padding: $mui-grid-padding/4 $mui-grid-padding; + padding: $mui-grid-padding * 0.25 $mui-grid-padding; cursor: pointer; position: relative; min-width: 2 * $mui-grid-padding; @@ -96,7 +231,7 @@ } .tabs__item--badge { - padding: $mui-grid-padding/4 0 $mui-grid-padding/4 $mui-grid-padding; + padding: $mui-grid-padding * 0.25 0 $mui-grid-padding * 0.25 $mui-grid-padding; } .tabs__item:hover, @@ -148,7 +283,7 @@ display: inline-block; background-color: transparentize($mui-primary-color, 0.85); border-radius: 2px; - padding: 0 $mui-grid-padding/2; + padding: 0 $mui-grid-padding * 0.5; font-size: 10px; text-transform: uppercase; font-weight: 600; @@ -167,8 +302,8 @@ } .badge--tab { - margin-right: $mui-grid-padding/4; - margin-left: $mui-grid-padding/4; + margin-right: $mui-grid-padding * 0.25; + margin-left: $mui-grid-padding * 0.25; border-radius: 16px; padding: 0 4px; width: auto; @@ -183,3 +318,15 @@ border: 1px solid transparent; background: transparentize($mui-primary-color, 0.85); } + +.md-tabset { + ul[role='tablist'] { + @extend .mui-tabs__bar; + a[role='tab'] { + @extend .mui--text-body2; + } + } + [role='tabpanel'] { + @extend .mui-tabs__pane, .top-padding; + } +} diff --git a/funnel/assets/sass/components/_ticket-modal.scss b/funnel/assets/sass/components/_ticket-modal.scss new file mode 100644 index 000000000..6da18efba --- /dev/null +++ b/funnel/assets/sass/components/_ticket-modal.scss @@ -0,0 +1,103 @@ +.tickets-wrapper { + .tickets-wrapper__modal { + display: none; + } + + .tickets-wrapper__modal__back { + display: none; + } + + .tickets-wrapper__modal__body__close { + display: block; + float: right; + } + + .tickets-wrapper__modal--show { + position: fixed; + top: 0; + background: $mui-bg-color-primary; + left: 0; + right: 0; + padding: $mui-grid-padding; + z-index: 1001; + bottom: 0; + overflow: auto; + + .tickets-wrapper__modal__back { + display: block; + } + } + .tickets-wrapper__modal--project-page.tickets-wrapper__modal--show { + top: 52px; // Below the header + } + .tickets-wrapper__modal--project-page .tickets-wrapper__modal__body__close { + display: none; + } +} + +@media (min-width: 768px) { + .tickets-wrapper { + .tickets-wrapper__modal { + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.75); + } + .tickets-wrapper__modal--project-page.tickets-wrapper__modal--show { + top: 0; + } + .tickets-wrapper__modal--project-page .tickets-wrapper__modal__body__close { + display: block; + } + + .tickets-wrapper__modal__body { + max-width: 500px; + margin: auto; + width: 90%; + padding: 0; + min-height: auto; + border-radius: 16px; + background-color: $mui-bg-color-primary; + overflow: auto; + .tickets-wrapper__modal__body__close { + margin-right: $mui-grid-padding * 0.5; + margin-top: $mui-grid-padding * 0.5; + } + } + } +} + +.price-btn { + min-width: 200px; + font-size: inherit; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; + margin: 0 0 2px; + justify-content: center; + height: 42px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.price-btn__txt { + display: block; + width: 100%; + font-size: 14px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} +.price-btn__txt--smaller { + font-size: 12px; + line-height: 16px; + text-transform: initial; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; + overflow: auto; +} diff --git a/funnel/assets/sass/form.scss b/funnel/assets/sass/form.scss index c4be2625e..fc5b85a84 100644 --- a/funnel/assets/sass/form.scss +++ b/funnel/assets/sass/form.scss @@ -4,6 +4,7 @@ 'mui/form'; @import 'base/variable', 'base/typography', 'components/draggablebox', 'components/switch', 'components/codemirror'; +@import 'node_modules/select2/dist/css/select2'; // ============================================================================ // Form @@ -15,13 +16,13 @@ .mui-form { .mui-form__fields { - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } .mui-textfield, .mui-radio, .mui-checkbox { - margin-bottom: $mui-grid-padding/4; + margin-bottom: $mui-grid-padding * 0.25; } .mui-radio, @@ -34,7 +35,7 @@ > input[type='radio'], > input[type='checkbox'] { position: relative; - margin: 0 $mui-grid-padding/2 0 0; + margin: 0 $mui-grid-padding * 0.5 0 0; } } } @@ -53,7 +54,7 @@ .mui-form__sidetext, .mui-form__helptext { color: $mui-text-light; - margin: $mui-grid-padding/2 0 0; + margin: $mui-grid-padding * 0.5 0 0; @extend .mui--text-caption; } @@ -87,23 +88,24 @@ } // Codemirror editor will be initialized - textarea.markdown { + textarea.markdown, + textarea.stylesheet { display: none; } .mui-form__label { position: static; - margin: 0 0 $mui-grid-padding/2; + margin: 0 0 $mui-grid-padding * 0.5; color: $mui-label-font-color; @extend .mui--text-subhead; } .mui-form__error { color: $mui-text-white; - margin: $mui-grid-padding/2 0 0; + margin: $mui-grid-padding * 0.5 0 0; background: $mui-text-danger; border: 1px solid transparentize($mui-text-danger, 0.8); - padding: $mui-grid-padding/2 $mui-grid-padding; + padding: $mui-grid-padding * 0.5 $mui-grid-padding; position: relative; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.14); border-radius: 2px; @@ -112,7 +114,7 @@ > li { list-style: none; - margin-top: $mui-grid-padding/2; + margin-top: $mui-grid-padding * 0.5; } } @@ -159,7 +161,7 @@ font-weight: 500; @extend .mui--text-body2; transform: none; - top: -$mui-grid-padding/4; + top: -$mui-grid-padding * 0.25; } } } @@ -174,10 +176,10 @@ // ============================================================================ .mui-select { - margin-top: $mui-grid-padding/2; + margin-top: $mui-grid-padding * 0.5; margin-bottom: 0; > label { - top: -$mui-grid-padding/4; + top: -$mui-grid-padding * 0.25; } .select2 { width: 100% !important; @@ -231,7 +233,7 @@ .imgee__url-holder { display: block; max-width: 200px; - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } .imgee__loader { @@ -243,7 +245,7 @@ } .imgee__button { - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } } @@ -301,9 +303,10 @@ .modal--form { min-width: 100%; - min-height: 325px; + min-height: 100%; height: 100%; border-radius: 0; + overflow: auto; .modal--form__action-box { padding: 20px; @@ -345,7 +348,6 @@ @media (min-width: 768px) { .jquery-modal.blocker.current .modal.modal--form { min-width: 50%; - min-height: 80%; height: auto; border-radius: 4px; } @@ -396,8 +398,7 @@ cursor: not-allowed; height: 32px !important; position: relative !important; - padding-left: $mui-grid-padding/2 !important; - width: calc(100% + $mui-grid-padding) !important; + padding-left: $mui-grid-padding * 0.5 !important; } .mui-select:focus > label, .mui-select > select:focus ~ label { @@ -414,23 +415,75 @@ } // ============================================================================ -// Keyboard switcher +// Input field helper icon (keyboard switch/Show password) // ============================================================================ -.tabs.keyboard-switch { +.field-toggle { display: none; - justify-content: space-between; - position: fixed; - left: 0; - bottom: 0; - width: 100%; - z-index: 2; - padding: $mui-grid-padding/2; - margin: 0; - background: $mui-bg-color-primary; - margin-bottom: calc(env(keyboard-inset-height)); // Position it above the keyboard -} - -input#username:focus ~ .keyboard-switch { - display: flex; +} + +// ============================================================================ +// Select2 +// ============================================================================ + +.select2-hidden-accessible ~ .mui-select__menu { + display: none !important; +} + +.select2-container .select2-selection { + border: none; + border-radius: 0; + background-image: none; + background-color: transparent; + border-bottom: 1px solid #ccc; + box-shadow: none; +} + +.select2-container .select2-dropdown { + border: none; + border-radius: 0; + -webkit-box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.12), + 0 1px 2px rgba(0, 0, 0, 0.24); + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.12), + 0 1px 2px rgba(0, 0, 0, 0.24); +} + +.select2-container.select2-container--focus .select2-selection, +.select2-container.select2-container--open .select2-selection { + box-shadow: none; + border: none; + border-bottom: 1px solid #ccc; +} + +.select2-container .select2-results__option--highlighted[aria-selected] { + background-color: #eee; + color: #1f2d3d; +} + +.select2-container .select2-selection--single .select2-selection__arrow { + background-color: transparent; + border: none; + background-image: none; +} + +// ============================================================================ +// Google Map in the form +// ============================================================================ + +.map { + position: relative; + .map__marker { + margin-top: $mui-grid-padding * 0.5; + width: 100%; + height: 40em; + } + .map__clear { + position: absolute; + top: 22px; + right: 0; + z-index: 2; + background: #fff; + } } diff --git a/funnel/assets/sass/mui/_buttons.scss b/funnel/assets/sass/mui/_buttons.scss index 2c0c5925f..d8bb914e1 100644 --- a/funnel/assets/sass/mui/_buttons.scss +++ b/funnel/assets/sass/mui/_buttons.scss @@ -3,31 +3,38 @@ */ @mixin x-btn-box-shadow-raised() { - box-shadow: 0 0px 2px rgba(mui-color('black'), 0.12), + box-shadow: + 0 0px 2px rgba(mui-color('black'), 0.12), 0 2px 2px rgba(mui-color('black'), 0.2); // IE10+ @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { - box-shadow: 0 -1px 2px rgba(mui-color('black'), 0.12), + box-shadow: + 0 -1px 2px rgba(mui-color('black'), 0.12), -1px 0px 2px rgba(mui-color('black'), 0.12), - 0 0px 2px rgba(mui-color('black'), 0.12), 0 2px 2px rgba(mui-color('black'), 0.2); + 0 0px 2px rgba(mui-color('black'), 0.12), + 0 2px 2px rgba(mui-color('black'), 0.2); } // Edge @supports (-ms-ime-align: auto) { - box-shadow: 0 -1px 2px rgba(mui-color('black'), 0.12), + box-shadow: + 0 -1px 2px rgba(mui-color('black'), 0.12), -1px 0px 2px rgba(mui-color('black'), 0.12), - 0 0px 2px rgba(mui-color('black'), 0.12), 0 2px 2px rgba(mui-color('black'), 0.2); + 0 0px 2px rgba(mui-color('black'), 0.12), + 0 2px 2px rgba(mui-color('black'), 0.2); } } @mixin x-btn-box-shadow-active() { - box-shadow: 0 0px 4px rgba(mui-color('black'), 0.12), + box-shadow: + 0 0px 4px rgba(mui-color('black'), 0.12), 1px 3px 4px rgba(mui-color('black'), 0.2); // IE10+ @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { - box-shadow: 0 -1px 2px rgba(mui-color('black'), 0.12), + box-shadow: + 0 -1px 2px rgba(mui-color('black'), 0.12), -1px 0px 2px rgba(mui-color('black'), 0.12), 0 0px 4px rgba(mui-color('black'), 0.12), 1px 3px 4px rgba(mui-color('black'), 0.2); @@ -35,7 +42,8 @@ // Edge @supports (-ms-ime-align: auto) { - box-shadow: 0 -1px 2px rgba(mui-color('black'), 0.12), + box-shadow: + 0 -1px 2px rgba(mui-color('black'), 0.12), -1px 0px 2px rgba(mui-color('black'), 0.12), 0 0px 4px rgba(mui-color('black'), 0.12), 1px 3px 4px rgba(mui-color('black'), 0.2); diff --git a/funnel/assets/sass/mui/_custom.scss b/funnel/assets/sass/mui/_custom.scss index 191b977b9..781d0873b 100644 --- a/funnel/assets/sass/mui/_custom.scss +++ b/funnel/assets/sass/mui/_custom.scss @@ -95,3 +95,9 @@ $mui-grid-gutter-width: 32px !default; // ============================================================================ $mui-panel-padding: 16px !default; + +// ============================================================================ +// TABS +// ============================================================================ + +$mui-tab-font-color: $mui-text-accent !default; diff --git a/funnel/assets/sass/mui/_form.scss b/funnel/assets/sass/mui/_form.scss index bfdb6f2bb..e560cd402 100644 --- a/funnel/assets/sass/mui/_form.scss +++ b/funnel/assets/sass/mui/_form.scss @@ -7,7 +7,7 @@ display: block; width: 100%; padding: 0; - margin-bottom: $mui-base-line-height-computed / 2; + margin-bottom: $mui-base-line-height-computed * 0.5; font-size: $mui-form-legend-font-size; color: $mui-form-legend-font-color; line-height: inherit; diff --git a/funnel/assets/sass/mui/_globals.scss b/funnel/assets/sass/mui/_globals.scss index 34082e6f4..43adce953 100644 --- a/funnel/assets/sass/mui/_globals.scss +++ b/funnel/assets/sass/mui/_globals.scss @@ -37,14 +37,14 @@ // paragraphs p { - margin: 0 0 ($mui-base-line-height-computed / 2); + margin: 0 0 ($mui-base-line-height-computed * 0.5); } // lists ul, ol { margin-top: 0; - margin-bottom: ($mui-base-line-height-computed / 2); + margin-bottom: ($mui-base-line-height-computed * 0.5); } // Horizontal rules @@ -92,14 +92,14 @@ h2, h3 { margin-top: $mui-base-line-height-computed; - margin-bottom: ($mui-base-line-height-computed / 2); + margin-bottom: ($mui-base-line-height-computed * 0.5); } h4, h5, h6 { - margin-top: ($mui-base-line-height-computed / 2); - margin-bottom: ($mui-base-line-height-computed / 2); + margin-top: ($mui-base-line-height-computed * 0.5); + margin-bottom: ($mui-base-line-height-computed * 0.5); } } @else { // Cherry pick from normalize.css diff --git a/funnel/assets/sass/mui/_helpers.scss b/funnel/assets/sass/mui/_helpers.scss index e18595852..b77a887ae 100644 --- a/funnel/assets/sass/mui/_helpers.scss +++ b/funnel/assets/sass/mui/_helpers.scss @@ -181,27 +181,32 @@ // ============================================================================ .mui--z1 { - box-shadow: 0 1px 3px rgba(mui-color('grey'), 0.12), + box-shadow: + 0 1px 3px rgba(mui-color('grey'), 0.12), 0 1px 2px rgba(mui-color('grey'), 0.24); } .mui--z2 { - box-shadow: 0 3px 6px rgba(mui-color('black'), 0.16), + box-shadow: + 0 3px 6px rgba(mui-color('black'), 0.16), 0 3px 6px rgba(mui-color('black'), 0.23); } .mui--z3 { - box-shadow: 0 10px 20px rgba(mui-color('black'), 0.19), + box-shadow: + 0 10px 20px rgba(mui-color('black'), 0.19), 0 6px 6px rgba(mui-color('black'), 0.23); } .mui--z4 { - box-shadow: 0 14px 28px rgba(mui-color('black'), 0.25), + box-shadow: + 0 14px 28px rgba(mui-color('black'), 0.25), 0 10px 10px rgba(mui-color('black'), 0.22); } .mui--z5 { - box-shadow: 0 19px 38px rgba(mui-color('black'), 0.3), + box-shadow: + 0 19px 38px rgba(mui-color('black'), 0.3), 0 15px 12px rgba(mui-color('black'), 0.22); } diff --git a/funnel/assets/sass/mui/_panel.scss b/funnel/assets/sass/mui/_panel.scss index 11567f36d..058604b13 100644 --- a/funnel/assets/sass/mui/_panel.scss +++ b/funnel/assets/sass/mui/_panel.scss @@ -9,12 +9,14 @@ margin-bottom: $mui-base-line-height-computed; border-radius: $mui-panel-border-radius; background-color: $mui-panel-bg-color; - box-shadow: 0 2px 2px 0 rgba(mui-color('black'), 0.16), + box-shadow: + 0 2px 2px 0 rgba(mui-color('black'), 0.16), 0 0px 2px 0 rgba(mui-color('black'), 0.12); // IE10+ bugfix @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { - box-shadow: 0 -1px 2px 0 rgba(mui-color('black'), 0.12), + box-shadow: + 0 -1px 2px 0 rgba(mui-color('black'), 0.12), -1px 0px 2px 0 rgba(mui-color('black'), 0.12), 0 2px 2px 0 rgba(mui-color('black'), 0.16), 0 0px 2px 0 rgba(mui-color('black'), 0.12); @@ -22,7 +24,8 @@ // Edge @supports (-ms-ime-align: auto) { - box-shadow: 0 -1px 2px 0 rgba(mui-color('black'), 0.12), + box-shadow: + 0 -1px 2px 0 rgba(mui-color('black'), 0.12), -1px 0px 2px 0 rgba(mui-color('black'), 0.12), 0 2px 2px 0 rgba(mui-color('black'), 0.16), 0 0px 2px 0 rgba(mui-color('black'), 0.12); diff --git a/funnel/assets/sass/mui/_ripple.scss b/funnel/assets/sass/mui/_ripple.scss index 156153b34..d183357d7 100644 --- a/funnel/assets/sass/mui/_ripple.scss +++ b/funnel/assets/sass/mui/_ripple.scss @@ -25,8 +25,10 @@ &.mui--is-animating { transform: none; - transition: transform 0.3s cubic-bezier(0, 0, 0.2, 1), - width 0.3s cubic-bezier(0, 0, 0.2, 1), height 0.3s cubic-bezier(0, 0, 0.2, 1), + transition: + transform 0.3s cubic-bezier(0, 0, 0.2, 1), + width 0.3s cubic-bezier(0, 0, 0.2, 1), + height 0.3s cubic-bezier(0, 0, 0.2, 1), opacity 0.3s cubic-bezier(0, 0, 0.2, 1); } diff --git a/funnel/assets/sass/mui/_tabs.scss b/funnel/assets/sass/mui/_tabs.scss index 1b0265cba..0b74a1183 100644 --- a/funnel/assets/sass/mui/_tabs.scss +++ b/funnel/assets/sass/mui/_tabs.scss @@ -1,7 +1,3 @@ -/** - * MUI Tabs module - */ - .mui-tabs__bar { list-style: none; padding-left: 0; diff --git a/funnel/assets/sass/mui/_variables.scss b/funnel/assets/sass/mui/_variables.scss index 583d9b817..77eba1dd8 100644 --- a/funnel/assets/sass/mui/_variables.scss +++ b/funnel/assets/sass/mui/_variables.scss @@ -157,7 +157,7 @@ $mui-label-font-color: mui-color('black-alpha-54') !default; $mui-label-margin-bottom: 3px !default; $mui-form-legend-font-size: $mui-base-font-size * 1.5 !default; -$mui-form-legend-margin-bottom: $mui-form-legend-font-size / 2 !default; +$mui-form-legend-margin-bottom: $mui-form-legend-font-size * 0.5 !default; $mui-form-legend-font-color: $mui-base-font-color !default; $mui-form-group-margin-bottom: 20px !default; @@ -216,6 +216,8 @@ $mui-table-border-color: $mui-divider-color !default; $mui-tab-font-color: $mui-base-font-color !default; $mui-tab-font-color-active: $mui-primary-color !default; $mui-tab-border-color-active: $mui-primary-color !default; +$mui-tab-font-color-hover: $mui-text-light !default; +$mui-tab-border-color-hover: $mui-text-light !default; // ============================================================================ // CARET diff --git a/funnel/assets/sass/mui/mixins/_grid-framework.scss b/funnel/assets/sass/mui/mixins/_grid-framework.scss index 297eadf1d..9aff1cc24 100644 --- a/funnel/assets/sass/mui/mixins/_grid-framework.scss +++ b/funnel/assets/sass/mui/mixins/_grid-framework.scss @@ -1,5 +1,7 @@ // Overrides bootstrap functions to add prfx support +@use 'sass:math'; + @mixin mui-make-grid-columns( $i: 1, $list: '.mui-col-xs-#{$i}, .mui-col-sm-#{$i}, .mui-col-md-#{$i}, .mui-col-lg-#{$i}' @@ -16,8 +18,8 @@ min-height: 1px; // Inner gutter via padding - padding-left: ($mui-grid-gutter-width / 2); - padding-right: ($mui-grid-gutter-width / 2); + padding-left: ($mui-grid-gutter-width * 0.5); + padding-right: ($mui-grid-gutter-width * 0.5); } } @@ -34,12 +36,12 @@ @mixin mui-calc-grid-column($i, $class, $type) { @if ($type == 'width') and ($i > 0) { .mui-col-#{$class}-#{$i} { - width: percentage(($i / $mui-grid-columns)); + width: percentage(math.div($i, $mui-grid-columns)); } } @if ($type == 'offset') { .mui-col-#{$class}-offset-#{$i} { - margin-left: percentage(($i / $mui-grid-columns)); + margin-left: percentage(math.div($i, $mui-grid-columns)); } } } diff --git a/funnel/assets/sass/mui/mixins/_util.scss b/funnel/assets/sass/mui/mixins/_util.scss index 40c69fe2c..27d70093d 100644 --- a/funnel/assets/sass/mui/mixins/_util.scss +++ b/funnel/assets/sass/mui/mixins/_util.scss @@ -27,8 +27,8 @@ box-sizing: border-box; margin-right: auto; margin-left: auto; - padding-left: ($gutter / 2); - padding-right: ($gutter / 2); + padding-left: ($gutter * 0.5); + padding-right: ($gutter * 0.5); } @mixin mui-tab-focus() { @@ -187,7 +187,7 @@ b, strong { - font-weight: 700er; + font-weight: 700; } /** diff --git a/funnel/assets/sass/pages/account.scss b/funnel/assets/sass/pages/account.scss index 77a7fc3c7..fe30e9588 100644 --- a/funnel/assets/sass/pages/account.scss +++ b/funnel/assets/sass/pages/account.scss @@ -54,12 +54,12 @@ float: left; } .preference__switch__txt--noswitch { - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } } .preference__title { width: calc(100% - 45px); - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; .preference__title__header { font-size: 14px; margin-bottom: 0; diff --git a/funnel/assets/sass/pages/comments.scss b/funnel/assets/sass/pages/comments.scss index 4e5e29ea3..2addbf5d4 100644 --- a/funnel/assets/sass/pages/comments.scss +++ b/funnel/assets/sass/pages/comments.scss @@ -67,7 +67,7 @@ align-content: center; .comment__header__expand { - margin-right: $mui-grid-padding/4; + margin-right: $mui-grid-padding * 0.25; } .mui-btn--nostyle + .mui-btn--nostyle { margin-left: 0; @@ -77,7 +77,7 @@ width: 100%; max-width: calc(100% - 36px); .commenter { - margin-right: $mui-grid-padding/4; + margin-right: $mui-grid-padding * 0.25; } .comment__header__details__user { min-height: 40px; @@ -86,8 +86,8 @@ padding-bottom: 0; } .badge { - margin-left: $mui-grid-padding/4; - margin-bottom: $mui-grid-padding/4; + margin-left: $mui-grid-padding * 0.25; + margin-bottom: $mui-grid-padding * 0.25; } } } @@ -102,17 +102,17 @@ blockquote { border-left: 3px solid $mui-text-accent; - padding: 0 0 0 $mui-grid-padding/2; + padding: 0 0 0 $mui-grid-padding * 0.5; } .comment__body__inner, .comment--children { - padding-left: $mui-grid-padding/2; + padding-left: $mui-grid-padding * 0.5; word-break: break-word; margin-bottom: 14px; } .js-comment-form { .ajax-form { - margin-top: $mui-grid-padding/2; + margin-top: $mui-grid-padding * 0.5; } > .comment__body__links.link-icon:first-child { padding-left: 0; @@ -151,7 +151,7 @@ right: 0; z-index: 1002; background: $mui-bg-color-primary; - padding: $mui-grid-padding/2 $mui-grid-padding 56px; + padding: $mui-grid-padding * 0.5 $mui-grid-padding 56px; margin: 0; overflow-y: scroll; @@ -183,7 +183,6 @@ .cm-editor { border: 1px solid $mui-primary-color; border-radius: 16px 16px 0 16px; - min-height: 56px; padding: $mui-grid-padding; height: auto; .cm-scroller { @@ -200,7 +199,7 @@ } .icon-btn { min-width: 40px; - margin: 0 0 0 8px; + margin: 0; } .user { display: inline-block; @@ -231,22 +230,17 @@ max-height: 100%; overflow-y: scroll; .mui-form { + align-items: center; .mui-form__fields .cm-editor { border: none; border-radius: 0; background: $mui-bg-color-primary; + min-height: 72px; // height of navbar on project page } } - .mui-btn--raised.icon-btn { - border-radius: 0; - box-shadow: none; - padding-bottom: 16px; - background: inherit; - } - .user { - padding-bottom: $mui-grid-padding/2; - padding-right: $mui-grid-padding; - margin-right: 0; + .user, + .icon-btn { + margin: 0 $mui-grid-padding * 0.5 0 0; } } .ajax-form--block.ajax-form--mob { diff --git a/funnel/assets/sass/pages/event.scss b/funnel/assets/sass/pages/event.scss index 878371c31..239380564 100644 --- a/funnel/assets/sass/pages/event.scss +++ b/funnel/assets/sass/pages/event.scss @@ -1,3 +1,6 @@ +@import '../base/mui_form_essentials'; +@import '../mui/select'; + .attendee-table .buttongrp-column { min-width: 250px; } diff --git a/funnel/assets/sass/pages/index.scss b/funnel/assets/sass/pages/index.scss index f404b5a19..522672d30 100644 --- a/funnel/assets/sass/pages/index.scss +++ b/funnel/assets/sass/pages/index.scss @@ -1,4 +1,5 @@ @import '../base/variable'; +@import '../components/ticket-modal'; .homepage { .logo-about { @@ -42,10 +43,11 @@ } } -.spotlight-container { - padding: 0 40px; - .spotlight-container__details { - margin-top: 40px; +@media (min-width: 992px) { + .spotlight-container { + .spotlight-container__details { + margin-top: 40px; + } } } @@ -132,7 +134,9 @@ } .card--spotlight:hover { - box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); + box-shadow: + 0 14px 28px rgba(0, 0, 0, 0.25), + 0 10px 10px rgba(0, 0, 0, 0.22); } .card--new { diff --git a/funnel/assets/sass/pages/login_form.scss b/funnel/assets/sass/pages/login_form.scss index 39cb2b7fa..d2091a3e1 100644 --- a/funnel/assets/sass/pages/login_form.scss +++ b/funnel/assets/sass/pages/login_form.scss @@ -72,7 +72,7 @@ left: 0; right: 0; text-align: right; - padding: $mui-grid-padding/2; + padding: $mui-grid-padding * 0.5; } .header, .footer { @@ -80,7 +80,7 @@ } .login-page__box { position: relative; - padding: ($mui-grid-padding * 2 + $mui-grid-padding/2) $mui-grid-padding * 2 + padding: ($mui-grid-padding * 2 + $mui-grid-padding * 0.5) $mui-grid-padding * 2 $mui-grid-padding * 2; border-radius: 16px; margin: 0 auto $mui-grid-padding * 2; @@ -171,16 +171,26 @@ a.loginbutton.hidden, background-color: $mui-bg-color-primary !important; } +// ============================================================================ +// Input field helper icon +// ============================================================================ + +.field-toggle { + padding: $mui-grid-padding * 0.5; + position: absolute; + right: -$mui-grid-padding * 0.5; + bottom: -$mui-grid-padding * 0.25; + float: right; + z-index: 3; +} + // ============================================================================ // Password field // ============================================================================ .mui-textfield--password { .password-toggle { - position: relative; - right: 0; - bottom: 22px; - float: right; + display: inline-block; } .password-strength-icon { position: absolute; @@ -193,10 +203,12 @@ a.loginbutton.hidden, } .progress { height: 4px; - margin: $mui-grid-padding/2 0 25px; + margin: $mui-grid-padding * 0.5 0 25px; background-color: #f5f5f5; border-radius: 4px; - box-shadow: 0 1px 3px $mui-box-shadow-grey, 0 1px 2px $mui-box-shadow-grey; + box-shadow: + 0 1px 3px $mui-box-shadow-grey, + 0 1px 2px $mui-box-shadow-grey; position: relative; display: none; @@ -249,3 +261,22 @@ a.loginbutton.hidden, .password-field-sidetext { margin: 0 0 20px; } + +// ============================================================================ +// Keyboard switcher +// ============================================================================ + +.mui-textfield { + .field-toggle:last-child { + right: 20px; + } + input#username:focus ~ .keyboard-switch .field-toggle { + display: inline-block; + } + .field-toggle { + color: $mui-text-accent; + } + .field-toggle.active { + color: $mui-text-light; + } +} diff --git a/funnel/assets/sass/pages/membership.scss b/funnel/assets/sass/pages/membership.scss index 9e4e85560..5cea115a7 100644 --- a/funnel/assets/sass/pages/membership.scss +++ b/funnel/assets/sass/pages/membership.scss @@ -3,7 +3,7 @@ @media (min-width: 1200px) { .membership-wrapper--half { float: left; - width: calc(50% - #{$mui-grid-padding/2}); + width: calc(50% - #{$mui-grid-padding * 0.5}); } .membership-wrapper--half:first-child { margin-right: $mui-grid-padding; diff --git a/funnel/assets/sass/pages/profile.scss b/funnel/assets/sass/pages/profile.scss index 3e02245d4..bf489b456 100644 --- a/funnel/assets/sass/pages/profile.scss +++ b/funnel/assets/sass/pages/profile.scss @@ -113,7 +113,7 @@ .profile-dropdown-btn { background-color: $mui-bg-color-primary-dark; - padding: $mui-grid-padding/2 $mui-grid-padding; + padding: $mui-grid-padding * 0.5 $mui-grid-padding; text-align: center; } @@ -173,7 +173,7 @@ .profile__logo__details { position: absolute; left: 136px; - bottom: -$mui-grid-padding/2; + bottom: -$mui-grid-padding * 0.5; min-width: 450px; } } @@ -199,7 +199,7 @@ font-size: 20px; margin-bottom: $mui-grid-padding; display: inline-block; - margin-right: $mui-grid-padding/2; + margin-right: $mui-grid-padding * 0.5; } } .profile-subheader__description { diff --git a/funnel/assets/sass/pages/project.scss b/funnel/assets/sass/pages/project.scss index 5369eb34b..9bb8650d9 100644 --- a/funnel/assets/sass/pages/project.scss +++ b/funnel/assets/sass/pages/project.scss @@ -3,6 +3,8 @@ @import '../components/proposal-card'; @import '../components/switch'; @import '../components/footable'; +@import '../components/draggablebox'; +@import '../components/ticket-modal'; @import 'leaflet'; @media (min-width: 768px) { @@ -16,7 +18,7 @@ width: 100%; left: 0; bottom: 0; - padding: $mui-grid-padding/4 $mui-grid-padding !important; + padding: $mui-grid-padding * 0.25 $mui-grid-padding $mui-grid-padding * 0.25 !important; box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.1); background-color: #ffffff; z-index: 1000; @@ -56,68 +58,93 @@ width: 100%; flex-wrap: wrap; .register-block__content { - width: 45%; - align-self: end; - } - .register-block__content--margin { - width: calc(55% - #{$mui-grid-padding}); - margin-right: $mui-grid-padding; - align-self: center; - } - .register-block__txt { - font-size: 12px; - margin: 0 0 $mui-grid-padding/2; - } - .register-block__btn { width: 100%; - font-size: 10px; - padding: 0 $mui-grid-padding/2; - } - .register-block__btn--small { - width: calc(100% - 30px); - } - .register-block__right__menu { - margin-left: $mui-grid-padding/2; + .register-block__content__rsvp-txt { + display: none; + } + .register-block__content__txt { + font-size: 9px; + line-height: 16px; + width: 100%; + display: block; + font-style: italic; + margin-bottom: $mui-grid-padding * 0.25; + } + .register-block__btn { + @extend .price-btn; + width: 100%; + .register-block__btn__txt { + @extend .price-btn__txt; + } + .register-block__btn__txt--hover--show { + display: none; + } + .register-block__btn__txt--smaller { + @extend .price-btn__txt--smaller; + } + &:hover .register-block__btn__txt--hover { + display: none; + } + &:hover .register-block__btn__txt--hover--show { + display: block; + color: $mui-text-danger; + } + &:hover { + border-color: $mui-text-danger; + } + } + .register-block__btn.mui--is-disabled:hover { + border-color: inherit; + } } - .register-block__btnwrapper { - width: 100%; + .register-block__content--half { + width: calc(50% - 8px); + align-self: flex-end; } - .register-block__btnwrapper--width { - width: auto; - display: inline-flex; + .register-block__content--half:first-child { + margin-right: 16px; } } -.project-footer { +@media (min-width: 380px) { .register-block { - .register-block__content--flex { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - flex-wrap: wrap; - } - .register-block__txt { - margin: 0; - } - .register-block__btnwrapper { - width: auto; - } - .register-block__btn.full-width-btn { - width: 100%; + .register-block__content { + .register-block__content__txt { + font-size: 10px; + } } } } -@media (min-width: 360px) { +@media (any-pointer: coarse) { .register-block { - .register-block__txt { - font-size: 14px; - white-space: normal; + .register-block__content { + .register-block__content__rsvp-txt { + display: block; + font-size: 12px; + font-weight: 500; + } + .register-block__btn { + border-color: $mui-text-danger; + .register-block__btn__txt--hover { + display: none; + } + .register-block__btn__txt--hover--show { + display: block; + color: $mui-text-danger; + } + .register-block__btn__txt--mobile { + display: none; + } + } + .register-block__btn.mui--is-disabled { + border-color: inherit; + } } - .register-block__txt--longer { - max-width: calc(100% - 30px); - display: inline-block; + .register-block__content--half { + .register-block__content__rsvp-txt { + font-size: 9px; + } } } } @@ -126,32 +153,29 @@ .project-footer { .register-block { display: block; - .register-block__content--flex { - display: block; - } - .register-block__content, - .register-block__content--margin { + .register-block__content { width: 100%; margin-right: 0; - } - .register-block__content--padding { - margin: $mui-grid-padding 0 0; - } - .register-block__txt { - margin: 0 0 $mui-grid-padding; - max-width: 100%; - } - .register-block__btn { - width: 100%; - font-size: inherit; - } - .register-block__btnwrapper { - width: 100%; - display: inline-flex; - align-items: center; - } - .register-block__btnwrapper--width { - width: auto; + margin-bottom: $mui-grid-padding; + .register-block__content__rsvp-txt { + font-size: 12px; + } + .register-block__content__txt { + font-size: 14px; + line-height: 21px; + margin-bottom: $mui-grid-padding * 0.5; + } + .register-block__btn { + width: 100%; + font-size: inherit; + height: 42px; + .register-block__btn__txt { + font-size: 14px; + } + .register-block__btn__txt--smaller { + font-size: 12px; + } + } } } } @@ -238,7 +262,8 @@ .project-banner__profile-details { display: flex; align-items: center; - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; + flex-wrap: wrap; .project-banner__profile-details__logo-wrapper { display: inline-block; @@ -246,7 +271,7 @@ width: 24px; border-radius: 50%; overflow: hidden; - margin-right: $mui-grid-padding/2; + margin-right: $mui-grid-padding * 0.5; .project-banner__profile-details__logo_wrapper__logo { height: 100%; @@ -254,6 +279,9 @@ object-fit: cover; } } + .project-banner__profile-details__badge { + margin-left: auto; + } } .project-banner__profile-details--center { @@ -326,7 +354,7 @@ .calendar__weekdays .calendar__month--latest, .calendar__weekdays .calendar__weekdays__dates--latest { display: flex; - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } .calendar__weekdays .calendar__weekdays__dates:last-child { @@ -412,7 +440,7 @@ @media (min-width: 992px) { .project-banner { .profile-text { - margin: 0 0 $mui-grid-padding/4; + margin: 0 0 $mui-grid-padding * 0.25; p { margin: 0; @@ -452,7 +480,7 @@ text-decoration: none !important; display: inline-block; width: 100%; - padding: $mui-grid-padding/2 0; + padding: $mui-grid-padding * 0.5 0; border-bottom: 1px solid $mui-divider-color; } .pinned__update__heading { @@ -461,6 +489,7 @@ } .pinned__update__body { margin-bottom: 0; + word-break: break-word; > * { display: inline-block; margin-bottom: 0; @@ -499,64 +528,6 @@ padding-bottom: $mui-grid-padding; } -.tickets-wrapper { - .tickets-wrapper__modal { - display: none; - } - - .tickets-wrapper__modal__back { - display: none; - } - - .tickets-wrapper__modal__body__close { - display: none; - } - - .tickets-wrapper__modal--show { - position: fixed; - top: 52px; - background: $mui-bg-color-primary; - left: 0; - right: 0; - padding: $mui-grid-padding; - z-index: 1001; - bottom: 0; - overflow: auto; - - .tickets-wrapper__modal__back { - display: block; - } - } -} - -@media (min-width: 768px) { - .tickets-wrapper { - .tickets-wrapper__modal { - top: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.75); - } - - .tickets-wrapper__modal__body { - max-width: 500px; - margin: auto; - width: 90%; - padding: 0; - min-height: auto; - border-radius: 16px; - background-color: $mui-bg-color-primary; - overflow: auto; - .tickets-wrapper__modal__body__close { - display: block; - float: right; - margin-right: $mui-grid-padding/2; - margin-top: $mui-grid-padding/2; - } - } - } -} - .about .rsvp-wrapper { padding-top: 50px; } @@ -564,7 +535,9 @@ @media (min-width: 768px) { .about .rsvp-wrapper { padding: 10px; - box-shadow: 0 1px 3px rgba(158, 158, 158, 0.12), 0 1px 2px rgba(158, 158, 158, 0.24); + box-shadow: + 0 1px 3px rgba(158, 158, 158, 0.12), + 0 1px 2px rgba(158, 158, 158, 0.24); } } @@ -597,13 +570,13 @@ flex-shrink: 0; margin-top: 0; border-right: 1px solid $mui-text-light; - padding-right: $mui-grid-padding/2; - margin-right: $mui-grid-padding/2; + padding-right: $mui-grid-padding * 0.5; + margin-right: $mui-grid-padding * 0.5; line-height: 1; } .setting__separator { display: block; - margin: 0 $mui-grid-padding/2; + margin: 0 $mui-grid-padding * 0.5; } .setting__sub { font-size: 16px; @@ -616,22 +589,6 @@ background: $mui-bg-color-primary; } -.map { - position: relative; - .map__marker { - margin-top: $mui-grid-padding/2; - width: 100%; - height: 40em; - } - .map__clear { - position: absolute; - top: 22px; - right: 0; - z-index: 2; - background: #fff; - } -} - .label { padding: 4px 8px; font-size: 12px; diff --git a/funnel/assets/sass/pages/schedule.scss b/funnel/assets/sass/pages/schedule.scss index 1483a50ef..3d2e60d50 100644 --- a/funnel/assets/sass/pages/schedule.scss +++ b/funnel/assets/sass/pages/schedule.scss @@ -43,13 +43,27 @@ .schedule__row__column__content__description { clear: both; - padding-top: $mui-grid-padding; + padding-top: $mui-grid-padding/4; padding-bottom: $mui-grid-padding; word-break: break-word; + a { + color: inherit; + } + img { width: 100%; } + + h1, + h2, + h3, + h4, + h5 { + font-size: 12px; + margin-top: 0; + margin-bottom: $mui-grid-padding/4; + } } p { @@ -94,11 +108,10 @@ .schedule__row--sticky { display: flex; - align-items: center; overflow-x: auto; position: sticky; position: -webkit-sticky; - top: 0; + top: 0; // header height in home page order: 1; z-index: 2; border: none; @@ -108,7 +121,7 @@ .schedule__row__column--header { display: flex !important; - padding: 0 $mui-grid-padding/2; + padding: 0 $mui-grid-padding * 0.5; margin: 0; align-items: center; justify-content: center; @@ -116,12 +129,14 @@ background-image: none !important; border-bottom: 2px solid $mui-divider-color; min-height: 50px; - min-width: 100px; + min-width: 60%; width: 100% !important; + padding: $mui-grid-padding/2; } .schedule__row__column--header.js-tab-active { - border-bottom: 2px solid $mui-accent-color; + background: transparentize($mui-primary-color, 0.85); + border-bottom-color: transparentize($mui-primary-color, 0.8); } .schedule__row__column--time { @@ -136,28 +151,34 @@ } @media (max-width: 767px) { - .schedule { - .schedule__row { - display: none; - height: auto !important; - - .schedule__row__column { + .schedule-grid { + .schedule { + .schedule__row { display: none; - .schedule__row__column__content { - min-height: 50px; + height: auto !important; + + .schedule__row__column { + display: none; + .schedule__row__column__content { + min-height: 50px; + } } } - } - .schedule__row--sticky { - display: flex; - } - .schedule__row.js-active { - display: flex; - .schedule__row__column.js-active { - display: block; + .schedule__row--sticky { + display: flex; + top: 36px; + } + .schedule__row.js-active { + display: flex; + .schedule__row__column.js-active { + display: block; + } } } } + .mobile-header .schedule-grid .schedule .schedule__row--sticky { + top: 52px; + } } @media (min-width: 768px) { @@ -187,7 +208,7 @@ .schedule__row__column__content { width: calc(100% - 1px); height: 100%; - padding: $mui-grid-padding $mui-grid-padding/2; + padding: $mui-grid-padding $mui-grid-padding * 0.5; .schedule__row__column__content__title__duration { top: 205px; } @@ -203,7 +224,7 @@ margin: 0; background-color: $mui-bg-color-primary; display: block; - padding: $mui-grid-padding/4 0 0; + padding: $mui-grid-padding * 0.25 0 0; text-align: center; color: $mui-accent-color; text-decoration: none; @@ -221,10 +242,12 @@ .schedule__row__column--header { outline: 1px solid $mui-divider-color; border: none !important; + background-color: $mui-bg-color-primary; } .schedule__row__column--time--header { display: block; - padding: $mui-grid-padding/2 0; + padding: $mui-grid-padding * 0.5 0; + align-self: center; } } .schedule__row--calendar { @@ -272,12 +295,12 @@ min-height: 100%; .modal__header { - padding: 0 $mui-grid-padding $mui-grid-padding/4; + padding: 0 $mui-grid-padding $mui-grid-padding * 0.25; border-bottom: 1px solid $mui-divider-color; .session-modal__title { max-width: calc(100% - 20px); - margin: $mui-grid-padding/2 0 $mui-grid-padding/2; + margin: $mui-grid-padding * 0.5 0 $mui-grid-padding * 0.5; position: static; } @@ -286,12 +309,12 @@ } .modal__header__title { - margin-bottom: $mui-grid-padding/4; + margin-bottom: $mui-grid-padding * 0.25; font-weight: 700; } .modal__header__text { - margin: 0 0 $mui-grid-padding/4; + margin: 0 0 $mui-grid-padding * 0.25; } } @@ -323,7 +346,7 @@ margin-top: 0; position: relative; top: -6px; - margin-bottom: $mui-grid-padding/2; + margin-bottom: $mui-grid-padding * 0.5; } } @@ -407,7 +430,7 @@ } .fc-event .fc-event-custom a { - font-weight: 700er; + font-weight: 700; color: #c33; font-size: 1.2em; } @@ -419,8 +442,8 @@ .proposal-box, body > .proposal-box.ui-draggable { - margin: $mui-grid-padding/2 0; - padding: $mui-grid-padding/4 $mui-grid-padding/2; + margin: $mui-grid-padding * 0.5 0; + padding: $mui-grid-padding * 0.25 $mui-grid-padding * 0.5; background: $mui-bg-color-primary; color: $mui-text-dark; border-radius: 4px; diff --git a/funnel/assets/sass/pages/submission.scss b/funnel/assets/sass/pages/submission.scss index 9ad0bab12..c2f891f6e 100644 --- a/funnel/assets/sass/pages/submission.scss +++ b/funnel/assets/sass/pages/submission.scss @@ -10,6 +10,7 @@ .details__box { position: relative; overflow: visible; + border-bottom: none; } } } @@ -99,7 +100,7 @@ } .mui--is-active.gallery__thumbnail { - background-color: rgba(255, 255, 255, 0.16); + background-color: $mui-bg-color-accent !important; } .gallery__thumbnail__play-icon { @@ -190,14 +191,28 @@ left: 0px; width: calc(100% - 16px); margin: 0px; - padding: $mui-grid-padding/2; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); + padding: $mui-grid-padding * 0.5; + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.12), + 0 1px 2px rgba(0, 0, 0, 0.24); background-color: white; margin: 0; - -webkit-transition: top 0.25s linear, z-index 0.1s step-start, opacity 0.25s linear; - -moz-transition: top 0.25s linear, z-index 0.1s step-start, opacity 0.25s linear; - -ms-transition: top 0.25s linear, z-index 0.1s step-start, opacity 0.25s linear; - -o-transition: top 0.25s linear, z-index 0.1s step-start, opacity 0.25s linear; + -webkit-transition: + top 0.25s linear, + z-index 0.1s step-start, + opacity 0.25s linear; + -moz-transition: + top 0.25s linear, + z-index 0.1s step-start, + opacity 0.25s linear; + -ms-transition: + top 0.25s linear, + z-index 0.1s step-start, + opacity 0.25s linear; + -o-transition: + top 0.25s linear, + z-index 0.1s step-start, + opacity 0.25s linear; max-width: 100%; legend { @@ -209,11 +224,26 @@ top: 0; z-index: 1000; opacity: 1; - -webkit-transition: top 0.25s linear, z-index 0.25s step-end, opacity 0.25s linear; - -moz-transition: top 0.25s linear, z-index 0.25s step-end, opacity 0.25s linear; - -ms-transition: top 0.25s linear, z-index 0.25s step-end, opacity 0.25s linear; - -o-transition: top 0.25s linear, z-index 0.25s step-end, opacity 0.25s linear; - transition: top 0.25s linear, z-index 0.25s step-end, opacity 0.25s linear; + -webkit-transition: + top 0.25s linear, + z-index 0.25s step-end, + opacity 0.25s linear; + -moz-transition: + top 0.25s linear, + z-index 0.25s step-end, + opacity 0.25s linear; + -ms-transition: + top 0.25s linear, + z-index 0.25s step-end, + opacity 0.25s linear; + -o-transition: + top 0.25s linear, + z-index 0.25s step-end, + opacity 0.25s linear; + transition: + top 0.25s linear, + z-index 0.25s step-end, + opacity 0.25s linear; } .listwidget { diff --git a/funnel/assets/sass/pages/submission_form.scss b/funnel/assets/sass/pages/submission_form.scss index 6cdf8b813..f3692d8a0 100644 --- a/funnel/assets/sass/pages/submission_form.scss +++ b/funnel/assets/sass/pages/submission_form.scss @@ -16,7 +16,7 @@ } } .mui-form__fields { - margin-bottom: $mui-grid-padding/4; + margin-bottom: $mui-grid-padding * 0.25; } #title { font-size: 18px; @@ -73,7 +73,7 @@ .submission-form { .submission-header { box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.15); - padding: $mui-grid-padding/2 0; + padding: $mui-grid-padding * 0.5 0; margin-bottom: $mui-grid-padding; position: sticky; top: $mui-header-height; diff --git a/funnel/assets/sass/pages/update.scss b/funnel/assets/sass/pages/update.scss index d1a5bac68..c6d2002cc 100644 --- a/funnel/assets/sass/pages/update.scss +++ b/funnel/assets/sass/pages/update.scss @@ -32,8 +32,8 @@ } .update { - padding: $mui-grid-padding/2 0 0; - margin: $mui-grid-padding/2 0; + padding: $mui-grid-padding * 0.5 0 0; + margin: $mui-grid-padding * 0.5 0; position: relative; .update__content { @@ -48,7 +48,7 @@ } .update--border { - padding: $mui-grid-padding/2 $mui-grid-padding 0; + padding: $mui-grid-padding * 0.5 $mui-grid-padding 0; border-radius: 4px; background: $mui-bg-color-accent; } diff --git a/funnel/assets/sass/reflex/_mixins.scss b/funnel/assets/sass/reflex/_mixins.scss index 8fcdfc43b..30dd8985d 100644 --- a/funnel/assets/sass/reflex/_mixins.scss +++ b/funnel/assets/sass/reflex/_mixins.scss @@ -6,6 +6,8 @@ // reflex grid generation mixins // -------------------------------------------------- +@use 'sass:math'; + @mixin make-reflex-grid($class) { @include loop-reflex-columns($reflex-columns, $class, width); } @@ -13,8 +15,8 @@ @mixin calc-reflex-columns($index, $class, $type) { @if $type == width and $index > 0 { .#{$reflex-prefix}#{$class}#{$index} { - width: percentage(($index / $reflex-columns)); - *width: percentage(($index / $reflex-columns)) - 0.1; + width: percentage(math.div($index, $reflex-columns)); + *width: percentage(math.div($index, $reflex-columns)) - 0.1; //for ie6 support you can uncomment this line but it will increase css filesize dramatically //@include setupCols(); diff --git a/funnel/assets/sass/reflex/_mixins_custom.scss b/funnel/assets/sass/reflex/_mixins_custom.scss index c72937cb9..2e0979b92 100644 --- a/funnel/assets/sass/reflex/_mixins_custom.scss +++ b/funnel/assets/sass/reflex/_mixins_custom.scss @@ -6,6 +6,8 @@ // reflex grid generation mixins // -------------------------------------------------- +@use 'sass:math'; + @mixin make-reflex-grid($class) { @include loop-reflex-columns($reflex-columns, $class, width); } @@ -13,8 +15,8 @@ @mixin calc-reflex-columns($index, $class, $type) { @if $type == width and $index > 0 { .#{$reflex-prefix}#{$class}#{$index} { - width: percentage(($index / $reflex-columns)); - *width: percentage(($index / $reflex-columns)) - 0.1; + width: percentage(math.div($index, $reflex-columns)); + *width: percentage(math.div($index, $reflex-columns)) - 0.1; //for ie6 support you can uncomment this line but it will increase css filesize dramatically //@include setupCols(); diff --git a/funnel/assets/sass/reflex/_variables.scss b/funnel/assets/sass/reflex/_variables.scss index 66fc6417a..6b3d050d8 100644 --- a/funnel/assets/sass/reflex/_variables.scss +++ b/funnel/assets/sass/reflex/_variables.scss @@ -62,6 +62,6 @@ $reflex-lg-max: ($reflex-xlg - 1); $reflex-grid-spacing: 16px !default; $reflex-cell-spacing: 16px !default; -$reflex-cell-spacing-sm: ($reflex-cell-spacing / 2); +$reflex-cell-spacing-sm: ($reflex-cell-spacing * 0.5); $reflex-cell-spacing-md: $reflex-cell-spacing; $reflex-cell-spacing-lg: ($reflex-cell-spacing * 2); diff --git a/funnel/assets/service-worker-template.js b/funnel/assets/service-worker-template.js index 33659ac2b..33e54e9eb 100644 --- a/funnel/assets/service-worker-template.js +++ b/funnel/assets/service-worker-template.js @@ -2,7 +2,11 @@ import { precacheAndRoute } from 'workbox-precaching'; import { registerRoute, setCatchHandler } from 'workbox-routing'; import { NetworkFirst, NetworkOnly } from 'workbox-strategies'; import { skipWaiting, clientsClaim } from 'workbox-core'; -precacheAndRoute(self.__WB_MANIFEST); +const filteredManifest = self.__WB_MANIFEST.filter((entry) => { + return !entry.url.match('prism-'); +}); + +precacheAndRoute(filteredManifest); skipWaiting(); clientsClaim(); diff --git a/funnel/cli/geodata.py b/funnel/cli/geodata.py index 68d199b84..978f03850 100644 --- a/funnel/cli/geodata.py +++ b/funnel/cli/geodata.py @@ -2,23 +2,21 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime -from decimal import Decimal -from typing import Optional -from urllib.parse import urljoin import csv import os import sys import time import zipfile +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from urllib.parse import urljoin -from flask.cli import AppGroup import click - -from unidecode import unidecode import requests import rich.progress +from flask.cli import AppGroup +from unidecode import unidecode from coaster.utils import getbool @@ -34,7 +32,7 @@ csv.field_size_limit(sys.maxsize) -geo = AppGroup('geoname', help="Process geoname data.") +geo = AppGroup('geonames', help="Process geonames data.") @dataclass @@ -111,7 +109,7 @@ class GeoAltNameRecord: is_historic: str -def downloadfile(basepath: str, filename: str, folder: Optional[str] = None) -> None: +def downloadfile(basepath: str, filename: str, folder: str | None = None) -> None: """Download a geoname record file.""" if not folder: folder_file = filename @@ -372,7 +370,7 @@ def load_admin1_codes(filename: str) -> None: if item.geonameid: rec = GeoAdmin1Code.query.get(item.geonameid) if rec is None: - rec = GeoAdmin1Code(geonameid=item.geonameid) + rec = GeoAdmin1Code(geonameid=int(item.geonameid)) db.session.add(rec) rec.title = item.title rec.ascii_title = item.ascii_title @@ -413,7 +411,7 @@ def load_admin2_codes(filename: str) -> None: @geo.command('download') def download() -> None: """Download geoname data.""" - os.makedirs('geoname_data', exist_ok=True) + os.makedirs('download/geonames', exist_ok=True) for filename in ( 'countryInfo.txt', 'admin1CodesASCII.txt', @@ -423,19 +421,19 @@ def download() -> None: 'alternateNames.zip', ): downloadfile( - 'http://download.geonames.org/export/dump/', filename, 'geoname_data' + 'http://download.geonames.org/export/dump/', filename, 'download/geonames' ) @geo.command('process') def process() -> None: """Process downloaded geonames data.""" - load_country_info('geoname_data/countryInfo.txt') - load_admin1_codes('geoname_data/admin1CodesASCII.txt') - load_admin2_codes('geoname_data/admin2Codes.txt') - load_geonames('geoname_data/IN.txt') - load_geonames('geoname_data/allCountries.txt') - load_alt_names('geoname_data/alternateNames.txt') + load_country_info('download/geonames/countryInfo.txt') + load_admin1_codes('download/geonames/admin1CodesASCII.txt') + load_admin2_codes('download/geonames/admin2Codes.txt') + load_geonames('download/geonames/IN.txt') + load_geonames('download/geonames/allCountries.txt') + load_alt_names('download/geonames/alternateNames.txt') app.cli.add_command(geo) diff --git a/funnel/cli/misc.py b/funnel/cli/misc.py index d5f924bf8..2c02c7ed0 100644 --- a/funnel/cli/misc.py +++ b/funnel/cli/misc.py @@ -2,9 +2,11 @@ from __future__ import annotations -from typing import Any, Dict +from pathlib import Path +from typing import Any import click +from dotenv import dotenv_values from baseframe import baseframe_translations @@ -13,7 +15,7 @@ @app.shell_context_processor -def shell_context() -> Dict[str, Any]: +def shell_context() -> dict[str, Any]: """Insert variables into flask shell locals.""" return {'db': db, 'models': models} @@ -45,3 +47,13 @@ def dbcreate() -> None: def baseframe_translations_path() -> None: """Show path to Baseframe translations.""" click.echo(list(baseframe_translations.translation_directories)[0]) + + +@app.cli.command('checkenv') +@click.argument('file', type=click.Path(exists=True, path_type=Path), default='.env') +def check_env(file: Path) -> None: + """Compare environment file with sample.env and lists variables that do not exist.""" + env = dotenv_values(file) + for var in dotenv_values('sample.env'): + if var not in env: + click.echo(var + ' does not exist') diff --git a/funnel/cli/periodic.py b/funnel/cli/periodic.py deleted file mode 100644 index 43e05b47b..000000000 --- a/funnel/cli/periodic.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Periodic maintenance actions.""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import timedelta -from typing import Any, Dict - -from flask.cli import AppGroup -import click - -from dateutil.relativedelta import relativedelta -import pytz -import requests - -from coaster.utils import midnight_to_utc, utcnow - -from .. import app, models -from ..models import db, sa -from ..views.notification import dispatch_notification - -# --- Data sources --------------------------------------------------------------------- - - -@dataclass -class DataSource: - """Source for data (query object and datetime column).""" - - basequery: Any - datecolumn: Any - - -def data_sources() -> Dict[str, DataSource]: - """Return sources for daily growth report.""" - return { - # `user_sessions`, `app_user_sessions` and `returning_users` (added below) are - # lookup keys, while the others are titles - 'user_sessions': DataSource( - models.UserSession.query.distinct(models.UserSession.user_id), - models.UserSession.accessed_at, - ), - 'app_user_sessions': DataSource( - db.session.query(sa.func.distinct(models.UserSession.user_id)) - .select_from(models.auth_client_user_session, models.UserSession) - .filter( - models.auth_client_user_session.c.user_session_id - == models.UserSession.id - ), - models.auth_client_user_session.c.accessed_at, - ), - "New users": DataSource( - models.User.query.filter(models.User.state.ACTIVE), - models.User.created_at, - ), - "RSVPs": DataSource( - models.Rsvp.query.filter(models.Rsvp.state.YES), models.Rsvp.created_at - ), - "Saved projects": DataSource( - models.SavedProject.query, models.SavedProject.saved_at - ), - "Saved sessions": DataSource( - models.SavedSession.query, models.SavedSession.saved_at - ), - } - - -# --- Commands ------------------------------------------------------------------------- - - -periodic = AppGroup( - 'periodic', help="Periodic tasks from cron (with recommended intervals)" -) - - -@periodic.command('project_starting_alert') -def project_starting_alert() -> None: - """Send notifications for projects that are about to start schedule (5m).""" - # Rollback to the most recent 5 minute interval, to account for startup delay - # for periodic job processes. - use_now = db.session.query( - sa.func.date_trunc('hour', sa.func.utcnow()) - + sa.cast(sa.func.date_part('minute', sa.func.utcnow()), sa.Integer) - / 5 - * timedelta(minutes=5) - ).scalar() - - # Find all projects that have a session starting between 10 and 15 minutes from - # use_now, and where the same project did not have a session ending within - # the prior hour. - - # Any eager-loading columns and relationships should be deferred with - # sa.orm.defer(column) and sa.orm.noload(relationship). There are none as of this - # commit. - for project in ( - models.Project.starting_at( - use_now + timedelta(minutes=10), - timedelta(minutes=5), - timedelta(minutes=60), - ) - .options(sa.orm.load_only(models.Project.uuid)) - .all() - ): - dispatch_notification( - models.ProjectStartingNotification( - document=project, - fragment=project.next_session_from(use_now + timedelta(minutes=10)), - ) - ) - - -@periodic.command('growthstats') -def growthstats() -> None: - """Publish growth statistics to Telegram (midnight).""" - if not app.config.get('TELEGRAM_STATS_APIKEY') or not app.config.get( - 'TELEGRAM_STATS_CHATID' - ): - raise click.UsageError( - "Configure TELEGRAM_STATS_APIKEY and TELEGRAM_STATS_CHATID in settings", - ) - # Dates in report timezone (for display) - tz = pytz.timezone('Asia/Kolkata') - now = utcnow().astimezone(tz) - display_date = now - relativedelta(days=1) - # Dates cast into UTC (for db queries) - today = midnight_to_utc(now) - yesterday = today - relativedelta(days=1) - two_days_ago = today - relativedelta(days=2) - last_week = today - relativedelta(weeks=1) - last_week_and_a_day = today - relativedelta(days=8) - two_weeks_ago = today - relativedelta(weeks=2) - last_month = today - relativedelta(months=1) - two_months_ago = today - relativedelta(months=2) - - stats = { - key: { - 'day': ds.basequery.filter( - ds.datecolumn >= yesterday, ds.datecolumn < today - ).count(), - 'day_before': ds.basequery.filter( - ds.datecolumn >= two_days_ago, ds.datecolumn < yesterday - ).count(), - 'weekday_before': ds.basequery.filter( - ds.datecolumn >= last_week_and_a_day, ds.datecolumn < last_week - ).count(), - 'week': ds.basequery.filter( - ds.datecolumn >= last_week, ds.datecolumn < today - ).count(), - 'week_before': ds.basequery.filter( - ds.datecolumn >= two_weeks_ago, ds.datecolumn < last_week - ).count(), - 'month': ds.basequery.filter( - ds.datecolumn >= last_month, ds.datecolumn < today - ).count(), - 'month_before': ds.basequery.filter( - ds.datecolumn >= two_months_ago, ds.datecolumn < last_month - ).count(), - } - for key, ds in data_sources().items() - } - - stats.update( - { - 'returning_users': { - # User from day before was active yesterday - 'day': models.UserSession.query.join(models.User) - .filter( - models.UserSession.accessed_at >= yesterday, - models.UserSession.accessed_at < today, - models.User.created_at >= two_days_ago, - models.User.created_at < yesterday, - ) - .distinct(models.UserSession.user_id) - .count(), - # User from last week was active this week - 'week': models.UserSession.query.join(models.User) - .filter( - models.UserSession.accessed_at >= last_week, - models.UserSession.accessed_at < today, - models.User.created_at >= two_weeks_ago, - models.User.created_at < last_week, - ) - .distinct(models.UserSession.user_id) - .count(), - # User from last month was active this month - 'month': models.UserSession.query.join(models.User) - .filter( - models.UserSession.accessed_at >= last_month, - models.UserSession.accessed_at < today, - models.User.created_at >= two_months_ago, - models.User.created_at < last_month, - ) - .distinct(models.UserSession.user_id) - .count(), - } - } - ) - - def trend_symbol(current: int, previous: int) -> str: - """Return a trend symbol based on difference between current and previous.""" - if current > previous * 1.5: - return '⏫' - if current > previous: - return '🔼' - if current == previous: - return '▶️' - if current * 1.5 < previous: - return '⏬' - return '🔽' - - for key in stats: - if key not in ('user_sessions', 'app_user_sessions', 'returning_users'): - for period in ('day', 'week', 'month'): - stats[key][period + '_trend'] = trend_symbol( - stats[key][period], stats[key][period + '_before'] - ) - stats[key]['weekday_trend'] = trend_symbol( - stats[key]['day'], stats[key]['weekday_before'] - ) - - message = ( - f"*Growth #statistics for {display_date.strftime('%a, %-d %b %Y')}*\n" - f"\n" - f"*Active users*, of which\n" - f"↝ also using other apps, and\n" - f"⟳ returning new users from last period\n\n" - f"*{display_date.strftime('%A')}:* {stats['user_sessions']['day']}" - f" ↝ {stats['app_user_sessions']['day']}" - f" ⟳ {stats['returning_users']['day']}\n" - f"*Week:* {stats['user_sessions']['week']}" - f" ↝ {stats['app_user_sessions']['week']}" - f" ⟳ {stats['returning_users']['week']}\n" - f"*Month:* {stats['user_sessions']['month']}" - f" ↝ {stats['app_user_sessions']['month']}" - f" ⟳ {stats['returning_users']['month']}\n" - f"\n" - ) - for key, data in stats.items(): - if key not in ('user_sessions', 'app_user_sessions', 'returning_users'): - message += ( - f"*{key}:*\n" - f"{data['day_trend']}{data['weekday_trend']} {data['day']} day," - f" {data['week_trend']} {data['week']} week," - f" {data['month_trend']} {data['month']} month\n" - f"\n" - ) - - requests.post( - f'https://api.telegram.org/bot{app.config["TELEGRAM_STATS_APIKEY"]}' - f'/sendMessage', - timeout=30, - data={ - 'chat_id': app.config['TELEGRAM_STATS_CHATID'], - 'parse_mode': 'markdown', - 'text': message, - }, - ) - - -app.cli.add_command(periodic) diff --git a/funnel/cli/periodic/__init__.py b/funnel/cli/periodic/__init__.py new file mode 100644 index 000000000..7d0e34674 --- /dev/null +++ b/funnel/cli/periodic/__init__.py @@ -0,0 +1,13 @@ +"""Periodic commands.""" + +from flask.cli import AppGroup + +from ... import app + +periodic = AppGroup( + 'periodic', help="Periodic tasks from cron (with recommended intervals)" +) + +from . import mnrl, notification, stats # noqa: F401 + +app.cli.add_command(periodic) diff --git a/funnel/cli/periodic/mnrl.py b/funnel/cli/periodic/mnrl.py new file mode 100644 index 000000000..32ed7289f --- /dev/null +++ b/funnel/cli/periodic/mnrl.py @@ -0,0 +1,279 @@ +""" +Validate Indian phone numbers against the Mobile Number Revocation List. + +About MNRL: https://mnrl.trai.gov.in/homepage +API details (requires login): https://mnrl.trai.gov.in/api_details, contents reproduced +here: + +.. list-table:: API Description + :header-rows: 1 + + * - № + - API Name + - API URL + - Method + - Remark + * - 1 + - Get MNRL Status + - https://mnrl.trai.gov.in/api/mnrl/status/{key} + - GET + - Returns the current status of MNRL. + * - 2 + - Get MNRL Files + - https://mnrl.trai.gov.in/api/mnrl/files/{key} + - GET + - Returns the summary of MNRL files, to be used for further API calls to get the + list of mobile numbers or download the file. + * - 3 + - Get MNRL + - https://mnrl.trai.gov.in/api/mnrl/json/{file_name}/{key} + - GET + - Returns the list of mobile numbers of the requested (.json) file. + * - 4 + - Download MNRL + - https://mnrl.trai.gov.in/api/mnrl/download/{file_name}/{key} + - GET + - Can be used to download the file. (xlsx, pdf, json, rar) +""" + +import asyncio + +import click +import httpx +import ijson +from rich import get_console, print as rprint +from rich.progress import Progress + +from ... import app +from ...models import AccountPhone, PhoneNumber, db +from . import periodic + + +class KeyInvalidError(ValueError): + """MNRL API key is invalid.""" + + message = "MNRL API key is invalid" + + +class KeyExpiredError(ValueError): + """MNRL API key has expired.""" + + message = "MNRL API key has expired" + + +class AsyncStreamAsFile: + """Provide a :meth:`read` interface to a HTTPX async stream response for ijson.""" + + def __init__(self, response: httpx.Response) -> None: + self.data = response.aiter_bytes() + + async def read(self, size: int) -> bytes: + """Async read method for ijson (which expects this to be 'read' not 'aread').""" + if size == 0: + # ijson calls with size 0 and expect b'', using it only to + # print a warning if the return value is '' (str instead of bytes) + return b'' + # Python >= 3.10 supports `return await anext(self.data, b'')` but for older + # versions we need this try/except block + try: + # Ignore size parameter since anext doesn't take it + # pylint: disable=unnecessary-dunder-call + return await self.data.__anext__() + except StopAsyncIteration: + return b'' + + +async def get_existing_phone_numbers(prefix: str) -> set[str]: + """Async wrapper for PhoneNumber.get_numbers.""" + # TODO: This is actually an async-blocking call. We need full stack async here. + return PhoneNumber.get_numbers(prefix=prefix, remove=True) + + +async def get_mnrl_json_file_list(apikey: str) -> list[str]: + """ + Return filenames for the currently published MNRL JSON files. + + TRAI publishes the MNRL as a monthly series of files in Excel, PDF and JSON + formats, of which we'll use JSON (plaintext format isn't offered). + """ + response = await httpx.AsyncClient(http2=True).get( + f'https://mnrl.trai.gov.in/api/mnrl/files/{apikey}', timeout=300 + ) + if response.status_code == 401: + raise KeyInvalidError() + if response.status_code == 407: + raise KeyExpiredError() + response.raise_for_status() + + result = response.json() + # Fallback tests for non-200 status codes in a 200 response (current API behaviour) + if result['status'] == 401: + raise KeyInvalidError() + if result['status'] == 407: + raise KeyExpiredError() + return [row['file_name'] for row in result['mnrl_files']['json']] + + +async def get_mnrl_json_file_numbers( + client: httpx.AsyncClient, apikey: str, filename: str +) -> tuple[str, set[str]]: + """Return phone numbers from an MNRL JSON file URL.""" + async with client.stream( + 'GET', + f'https://mnrl.trai.gov.in/api/mnrl/json/{filename}/{apikey}', + timeout=300, + ) as response: + response.raise_for_status() + # The JSON structure is {"payload": [{"n": "number"}, ...]} + # The 'item' in 'payload.item' is ijson's code for array elements + return filename, { + value + async for key, value in ijson.kvitems( + AsyncStreamAsFile(response), 'payload.item' + ) + if key == 'n' and value is not None + } + + +async def forget_phone_numbers(phone_numbers: set[str], prefix: str) -> None: + """Mark phone numbers as forgotten.""" + for unprefixed in phone_numbers: + number = prefix + unprefixed + userphone = AccountPhone.get(number) + if userphone is not None: + # TODO: Dispatch a notification to userphone.account, but since the + # notification will not know the phone number (it'll already be forgotten), + # we need a new db model to contain custom messages + # TODO: Also delay dispatch until the full MNRL scan is complete -- their + # backup contact phone number may also have expired. That means this + # function will create notifications and return them, leaving dispatch to + # the outermost function + rprint(f"{userphone} - owned by {userphone.account.pickername}") + # TODO: MNRL isn't foolproof. Don't delete! Instead, notify the user and + # only delete if they don't respond (How? Maybe delete and send them a + # re-add token?) + # db.session.delete(userphone) + phone_number = PhoneNumber.get(number) + if phone_number is not None: + rprint( + f"{phone_number} - since {phone_number.created_at:%Y-%m-%d}, updated" + f" {phone_number.updated_at:%Y-%m-%d}" + ) + # phone_number.mark_forgotten() + db.session.commit() + + +async def process_mnrl_files( + apikey: str, + existing_phone_numbers: set[str], + phone_prefix: str, + mnrl_filenames: list[str], +) -> tuple[set[str], int, int]: + """ + Scan all MNRL files and return a tuple of results. + + :return: Tuple of number to be revoked (set), total expired numbers in the MNRL, + and count of failures when accessing the MNRL lists + """ + revoked_phone_numbers: set[str] = set() + mnrl_total_count = 0 + failures = 0 + async_tasks: set[asyncio.Task] = set() + with Progress(transient=True) as progress: + ptask = progress.add_task( + f"Processing {len(mnrl_filenames)} MNRL files", total=len(mnrl_filenames) + ) + async with httpx.AsyncClient( + http2=True, limits=httpx.Limits(max_connections=3) + ) as client: + for future in asyncio.as_completed( + [ + get_mnrl_json_file_numbers(client, apikey, filename) + for filename in mnrl_filenames + ] + ): + try: + filename, mnrl_set = await future + except httpx.HTTPError as exc: + progress.advance(ptask) + failures += 1 + # Extract filename from the URL (ends with /filename/apikey) as we + # can't get any context from asyncio.as_completed's future + filename = exc.request.url.path.split('/')[-2] + progress.update(ptask, description=f"Error in {filename}...") + if isinstance(exc, httpx.HTTPStatusError): + rprint( + f"[red]{filename}: Server returned HTTP status code" + f" {exc.response.status_code}" + ) + else: + rprint(f"[red]{filename}: Failed with {exc!r}") + else: + progress.advance(ptask) + mnrl_total_count += len(mnrl_set) + progress.update(ptask, description=f"Processing {filename}...") + found_expired = existing_phone_numbers.intersection(mnrl_set) + if found_expired: + revoked_phone_numbers.update(found_expired) + rprint( + f"[blue]{filename}: {len(found_expired):,} matches in" + f" {len(mnrl_set):,} total" + ) + async_tasks.add( + asyncio.create_task( + forget_phone_numbers(found_expired, phone_prefix) + ) + ) + else: + rprint( + f"[cyan]{filename}: No matches in {len(mnrl_set):,} total" + ) + + # Await all the background tasks + for task in async_tasks: + try: + # TODO: Change this to `notifications = await task` then return them too + await task + except Exception as exc: # noqa: B902 # pylint: disable=broad-except + app.logger.exception("%s in forget_phone_numbers", repr(exc)) + return revoked_phone_numbers, mnrl_total_count, failures + + +async def process_mnrl(apikey: str) -> None: + """Process MNRL data using the API key.""" + console = get_console() + phone_prefix = '+91' + task_numbers = asyncio.create_task(get_existing_phone_numbers(phone_prefix)) + task_files = asyncio.create_task(get_mnrl_json_file_list(apikey)) + with console.status("Loading phone numbers..."): + existing_phone_numbers = await task_numbers + rprint(f"Evaluating {len(existing_phone_numbers):,} phone numbers for expiry") + try: + with console.status("Getting MNRL download list..."): + mnrl_filenames = await task_files + except httpx.HTTPError as exc: + err = f"{exc!r} in MNRL API getting download list" + rprint(f"[red]{err}") + raise click.ClickException(err) + + revoked_phone_numbers, mnrl_total_count, failures = await process_mnrl_files( + apikey, existing_phone_numbers, phone_prefix, mnrl_filenames + ) + rprint( + f"Processed {mnrl_total_count:,} expired phone numbers in MNRL with" + f" {failures:,} failure(s) and revoked {len(revoked_phone_numbers):,} phone" + f" numbers" + ) + + +@periodic.command('mnrl') +def periodic_mnrl() -> None: + """Remove expired phone numbers using TRAI's MNRL (1 week).""" + apikey = app.config.get('MNRL_API_KEY') + if not apikey: + raise click.UsageError("App config is missing `MNRL_API_KEY`") + try: + asyncio.run(process_mnrl(apikey)) + except (KeyInvalidError, KeyExpiredError) as exc: + app.logger.error(exc.message) + raise click.ClickException(exc.message) from exc diff --git a/funnel/cli/periodic/notification.py b/funnel/cli/periodic/notification.py new file mode 100644 index 000000000..5d0c62315 --- /dev/null +++ b/funnel/cli/periodic/notification.py @@ -0,0 +1,46 @@ +"""Periodic scans for notifications to be sent out.""" + +from __future__ import annotations + +from datetime import timedelta + +from ... import models +from ...models import db, sa +from ...views.notification import dispatch_notification +from . import periodic + + +@periodic.command('project_starting_alert') +def project_starting_alert() -> None: + """Send notifications for projects that are about to start schedule (5m).""" + # Rollback to the most recent 5 minute interval, to account for startup delay + # for periodic job processes. + use_now = db.session.query( + sa.func.date_trunc('hour', sa.func.utcnow()) + + sa.cast(sa.func.date_part('minute', sa.func.utcnow()), sa.Integer) + / 5 + * timedelta(minutes=5) + ).scalar() + + # Find all projects that have a session starting between 10 and 15 minutes from + # use_now, and where the same project did not have a session ending within + # the prior hour. + + # Any eager-loading columns and relationships should be deferred with + # sa.orm.defer(column) and sa.orm.noload(relationship). There are none as of this + # commit. + for project in ( + models.Project.starting_at( + use_now + timedelta(minutes=10), + timedelta(minutes=5), + timedelta(minutes=60), + ) + .options(sa.orm.load_only(models.Project.uuid)) + .all() + ): + dispatch_notification( + models.ProjectStartingNotification( + document=project, + fragment=project.next_session_from(use_now + timedelta(minutes=10)), + ) + ) diff --git a/funnel/cli/periodic/stats.py b/funnel/cli/periodic/stats.py new file mode 100644 index 000000000..a36d783a4 --- /dev/null +++ b/funnel/cli/periodic/stats.py @@ -0,0 +1,466 @@ +"""Periodic statistics.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, cast, overload +from urllib.parse import unquote + +import click +import httpx +import pytz +import telegram +from dataclasses_json import DataClassJsonMixin +from dateutil.relativedelta import relativedelta +from furl import furl + +from coaster.utils import midnight_to_utc, utcnow + +from ... import app, models +from ...models import Mapped, Query, db, sa +from . import periodic + +# --- Data structures ------------------------------------------------------------------ + + +def trend_symbol(current: int, previous: int) -> str: + """Return a trend symbol based on difference between current and previous.""" + if current > previous * 1.5: + return '⏫' + if current > previous: + return '🔼' + if current == previous: + return '⏸️' + if current * 1.5 < previous: + return '⏬' + return '🔽' + + +@dataclass +class DataSource: + """Source for data (query object and datetime column).""" + + basequery: Query + datecolumn: Mapped[datetime] + + +@dataclass +class ResourceStats: + """Periodic counts for a resource.""" + + day: int + week: int + month: int + # The previous period counts are optional + day_before: int = 0 + weekday_before: int = 0 + week_before: int = 0 + month_before: int = 0 + # Trend symbols are also optional + day_trend: str = '' + weekday_trend: str = '' + week_trend: str = '' + month_trend: str = '' + + def set_trend_symbols(self) -> None: + self.day_trend = trend_symbol(self.day, self.day_before) + self.weekday_trend = trend_symbol(self.day, self.weekday_before) + self.week_trend = trend_symbol(self.week, self.week_before) + self.month_trend = trend_symbol(self.month, self.month_before) + + +@dataclass +class MatomoResponse(DataClassJsonMixin): + """Data in Matomo's API response.""" + + label: str = '' + nb_visits: int = 0 + nb_uniq_visitors: int = 0 + nb_users: int = 0 + url: str | None = None + segment: str = '' + + def get_url(self) -> str | None: + url = self.url + if url: + # If URL is a path (/path) or schemeless (//host/path), return as is + if url.startswith('/'): + return url + # If there's no leading `/` and no `://`, prefix `https://` + if '://' not in url: + return f'https://{url}' + # If neither, assume fully formed URL and return as is + return url + # If there's no URL in the data, look for a URL in the segment identifier + if self.segment.startswith('pageUrl='): + # Known prefixes: `pageUrl==` and `pageUrl=^` (9 chars) + # The rest of the string is double escaped, so unquote twice + return unquote(unquote(self.segment[9:])) + return None + + +@dataclass +class MatomoData: + """Matomo API data.""" + + referrers: Sequence[MatomoResponse] + socials: Sequence[MatomoResponse] + pages: Sequence[MatomoResponse] + visits_day: MatomoResponse | None = None + visits_week: MatomoResponse | None = None + visits_month: MatomoResponse | None = None + + +# --- Matomo analytics ----------------------------------------------------------------- + + +@overload +async def matomo_response_json( + client: httpx.AsyncClient, url: str, sequence: Literal[True] = True +) -> Sequence[MatomoResponse]: + ... + + +@overload +async def matomo_response_json( + client: httpx.AsyncClient, url: str, sequence: Literal[False] +) -> MatomoResponse | None: + ... + + +async def matomo_response_json( + client: httpx.AsyncClient, url: str, sequence: bool = True +) -> MatomoResponse | Sequence[MatomoResponse] | None: + """Process Matomo's JSON response.""" + try: + response = await client.get(url, timeout=30) + response.raise_for_status() + result = response.json() + if sequence: + if isinstance(result, list): + return [MatomoResponse.from_dict(r) for r in result] + return [] # Expected a list but didn't get one; treat as invalid response + return MatomoResponse.from_dict(result) + except httpx.HTTPError: + return [] if sequence else None + + +async def matomo_stats() -> MatomoData: + """Get stats from Matomo.""" + tz = pytz.timezone(app.config['TIMEZONE']) + now = utcnow().astimezone(tz) + today = midnight_to_utc(now) + yesterday = today - relativedelta(days=1) + last_week = yesterday - relativedelta(weeks=1) + last_month = yesterday - relativedelta(months=1) + week_range = f'{last_week.strftime("%Y-%m-%d")},{yesterday.strftime("%Y-%m-%d")}' + month_range = f'{last_month.strftime("%Y-%m-%d")},{yesterday.strftime("%Y-%m-%d")}' + if ( + not app.config.get('MATOMO_URL') + or not app.config.get('MATOMO_ID') + or not app.config.get('MATOMO_TOKEN') + ): + # No Matomo config + return MatomoData(referrers=[], socials=[], pages=[]) + matomo_url = furl(app.config['MATOMO_URL']) + matomo_url.add( + { + 'token_auth': app.config['MATOMO_TOKEN'], + 'module': 'API', + 'idSite': app.config['MATOMO_ID'], + 'filter_limit': 10, # Get top 10 + 'format': 'json', + } + ) + referrers_url = matomo_url.copy().add( + { + 'method': 'Referrers.getWebsites', + 'period': 'day', + 'date': 'yesterday', + } + ) + socials_url = matomo_url.copy().add( + { + 'method': 'Referrers.getSocials', + 'period': 'day', + 'date': 'yesterday', + } + ) + pages_url = matomo_url.copy().add( + { + 'method': 'Actions.getPageUrls', + 'period': 'day', + 'date': 'yesterday', + } + ) + visits_day_url = matomo_url.copy().add( + { + 'method': 'VisitsSummary.get', + 'period': 'day', + 'date': 'yesterday', + } + ) + visits_week_url = matomo_url.copy().add( + { + 'method': 'VisitsSummary.get', + 'period': 'range', + 'date': week_range, + } + ) + visits_month_url = matomo_url.copy().add( + { + 'method': 'VisitsSummary.get', + 'period': 'range', + 'date': month_range, + } + ) + + async with httpx.AsyncClient(follow_redirects=True) as client: + ( + referrers, + socials, + pages, + visits_day, + visits_week, + visits_month, + ) = await asyncio.gather( + matomo_response_json(client, str(referrers_url)), + matomo_response_json(client, str(socials_url)), + matomo_response_json(client, str(pages_url)), + matomo_response_json(client, str(visits_day_url), sequence=False), + matomo_response_json(client, str(visits_week_url), sequence=False), + matomo_response_json(client, str(visits_month_url), sequence=False), + ) + return MatomoData( + referrers=referrers, + socials=socials, + pages=pages, + visits_day=visits_day, + visits_week=visits_week, + visits_month=visits_month, + ) + + +# --- Internal database analytics ------------------------------------------------------ + + +def data_sources() -> dict[str, DataSource]: + """Return sources for daily stats report.""" + return { + # `login_sessions`, `app_login_sessions` and `returning_users` (added below) are + # lookup keys, while the others are titles + 'login_sessions': DataSource( + models.LoginSession.query.distinct(models.LoginSession.account_id), + models.LoginSession.accessed_at, + ), + 'app_login_sessions': DataSource( + db.session.query(sa.func.distinct(models.LoginSession.account_id)) + .select_from(models.auth_client_login_session, models.LoginSession) + .filter( + models.auth_client_login_session.c.login_session_id + == models.LoginSession.id + ), + cast(Mapped[datetime], models.auth_client_login_session.c.accessed_at), + ), + "New users": DataSource( + models.Account.query.filter(models.Account.state.ACTIVE), + models.Account.created_at, + ), + "RSVPs": DataSource( + models.Rsvp.query.filter(models.Rsvp.state.YES), models.Rsvp.created_at + ), + "Saved projects": DataSource( + models.SavedProject.query, models.SavedProject.saved_at + ), + "Saved sessions": DataSource( + models.SavedSession.query, models.SavedSession.saved_at + ), + } + + +async def user_stats() -> dict[str, ResourceStats]: + """Retrieve user statistics from internal database.""" + # Dates in report timezone (for display) + tz = pytz.timezone(app.config['TIMEZONE']) + now = utcnow().astimezone(tz) + # Dates cast into UTC (for db queries) + today = midnight_to_utc(now) + yesterday = today - relativedelta(days=1) + two_days_ago = today - relativedelta(days=2) + last_week = today - relativedelta(weeks=1) + last_week_and_a_day = today - relativedelta(days=8) + two_weeks_ago = today - relativedelta(weeks=2) + last_month = today - relativedelta(months=1) + two_months_ago = today - relativedelta(months=2) + + stats: dict[str, ResourceStats] = { + key: ResourceStats( + day=ds.basequery.filter( + ds.datecolumn >= yesterday, ds.datecolumn < today + ).count(), + day_before=ds.basequery.filter( + ds.datecolumn >= two_days_ago, ds.datecolumn < yesterday + ).count(), + weekday_before=ds.basequery.filter( + ds.datecolumn >= last_week_and_a_day, ds.datecolumn < last_week + ).count(), + week=ds.basequery.filter( + ds.datecolumn >= last_week, ds.datecolumn < today + ).count(), + week_before=ds.basequery.filter( + ds.datecolumn >= two_weeks_ago, ds.datecolumn < last_week + ).count(), + month=ds.basequery.filter( + ds.datecolumn >= last_month, ds.datecolumn < today + ).count(), + month_before=ds.basequery.filter( + ds.datecolumn >= two_months_ago, ds.datecolumn < last_month + ).count(), + ) + for key, ds in data_sources().items() + } + + stats.update( + { + 'returning_users': ResourceStats( + # User from day before was active yesterday + day=models.LoginSession.query.join(models.Account) + .filter( + models.LoginSession.accessed_at >= yesterday, + models.LoginSession.accessed_at < today, + models.Account.created_at >= two_days_ago, + models.Account.created_at < yesterday, + ) + .distinct(models.LoginSession.account_id) + .count(), + # User from last week was active this week + week=models.LoginSession.query.join(models.Account) + .filter( + models.LoginSession.accessed_at >= last_week, + models.LoginSession.accessed_at < today, + models.Account.created_at >= two_weeks_ago, + models.Account.created_at < last_week, + ) + .distinct(models.LoginSession.account_id) + .count(), + # User from last month was active this month + month=models.LoginSession.query.join(models.Account) + .filter( + models.LoginSession.accessed_at >= last_month, + models.LoginSession.accessed_at < today, + models.Account.created_at >= two_months_ago, + models.Account.created_at < last_month, + ) + .distinct(models.LoginSession.account_id) + .count(), + ) + } + ) + + for key in stats: + if key not in ('login_sessions', 'app_login_sessions', 'returning_users'): + stats[key].set_trend_symbols() + + return stats + + +# --- Commands ------------------------------------------------------------------------- + + +async def dailystats() -> None: + """Publish daily stats to Telegram.""" + if ( + not app.config.get('TELEGRAM_STATS_APIKEY') + or not app.config.get('TELEGRAM_STATS_CHATID') + or not app.config.get('TIMEZONE') + ): + raise click.UsageError( + "Configure TELEGRAM_STATS_APIKEY, TELEGRAM_STATS_CHATID and TIMEZONE in" + " settings", + ) + + tz = pytz.timezone(app.config['TIMEZONE']) + now = utcnow().astimezone(tz) + display_date = now - relativedelta(days=1) + + user_data, matomo_data = await asyncio.gather(user_stats(), matomo_stats()) + message = ( + f"*Traffic #statistics for {display_date.strftime('%a, %-d %b %Y')}*\n" + f"\n" + f"*Active users*, of which\n" + f"→ logged in, and\n" + f"↝ also using other apps, and\n" + f"⟳ returning new registered users from last period\n\n" + f"*{display_date.strftime('%A')}:*" + ) + if matomo_data.visits_day: + message += f' {matomo_data.visits_day.nb_uniq_visitors}' + message += ( + f" → {user_data['login_sessions'].day}" + f" ↝ {user_data['app_login_sessions'].day}" + f" ⟳ {user_data['returning_users'].day}\n" + f"*Week:*" + ) + if matomo_data.visits_week: + message += f' {matomo_data.visits_week.nb_uniq_visitors}' + message += ( + f" → {user_data['login_sessions'].week}" + f" ↝ {user_data['app_login_sessions'].week}" + f" ⟳ {user_data['returning_users'].week}\n" + f"*Month:*" + ) + if matomo_data.visits_month: + message += f' {matomo_data.visits_month.nb_uniq_visitors}' + message += ( + f" → {user_data['login_sessions'].month}" + f" ↝ {user_data['app_login_sessions'].month}" + f" ⟳ {user_data['returning_users'].month}\n" + f"\n" + ) + for key, data in user_data.items(): + if key not in ('login_sessions', 'app_login_sessions', 'returning_users'): + message += ( + f"*{key}:*\n" + f"{data.day_trend}{data.weekday_trend} {data.day} day," + f" {data.week_trend} {data.week} week," + f" {data.month_trend} {data.month} month\n" + f"\n" + ) + + if matomo_data.pages: + message += "\n*Top pages:* _(by visits)_\n" + for mdata in matomo_data.pages: + url = mdata.get_url() + if url: + message += f"{mdata.nb_visits}: [{mdata.label.strip()}]({url})\n" + else: + message += f"{mdata.nb_visits}: {mdata.label.strip()}\n" + + if matomo_data.referrers: + message += "\n*Referrers:*\n" + for mdata in matomo_data.referrers: + message += f"{mdata.nb_visits}: {mdata.label.strip()}\n" + + if matomo_data.socials: + message += "\n*Socials:*\n" + for mdata in matomo_data.socials: + message += f"{mdata.nb_visits}: {mdata.label.strip()}\n" + + bot = telegram.Bot(app.config["TELEGRAM_STATS_APIKEY"]) + await bot.send_message( + text=message, + parse_mode='markdown', + chat_id=app.config['TELEGRAM_STATS_CHATID'], + disable_notification=True, + disable_web_page_preview=True, + message_thread_id=app.config.get('TELEGRAM_STATS_THREADID'), + ) + + +@periodic.command('dailystats') +def periodic_dailystats() -> None: + """Publish daily stats to Telegram (midnight).""" + asyncio.run(dailystats()) diff --git a/funnel/cli/refresh/markdown.py b/funnel/cli/refresh/markdown.py index 9f3df7543..17e9d0086 100644 --- a/funnel/cli/refresh/markdown.py +++ b/funnel/cli/refresh/markdown.py @@ -2,41 +2,43 @@ from __future__ import annotations -from typing import ClassVar, Dict, List, Optional, Set +from collections.abc import Iterable +from typing import ClassVar, Generic, TypeVar import click - import rich.progress from ... import models -from ...models import db, sa +from ...models import MarkdownModelUnion, db, sa from . import refresh +_M = TypeVar('_M', bound=MarkdownModelUnion) + -class MarkdownModel: +class MarkdownModel(Generic[_M]): """Holding class for a model that has markdown fields with custom configuration.""" - registry: ClassVar[Dict[str, MarkdownModel]] = {} - config_registry: ClassVar[Dict[str, Set[MarkdownModel]]] = {} + registry: ClassVar[dict[str, MarkdownModel]] = {} + config_registry: ClassVar[dict[str, set[MarkdownModel]]] = {} - def __init__(self, model, fields: Set[str]): + def __init__(self, model: type[_M], fields: set[str]) -> None: self.name = model.__tablename__ self.model = model self.fields = fields - self.config_fields: Dict[str, Set[str]] = {} + self.config_fields: dict[str, set[str]] = {} for field in fields: config = getattr(model, field).original_property.composite_class.config.name self.config_fields.setdefault(config, set()).add(field) @classmethod - def register(cls, model, fields: Set[str]): + def register(cls, model: type[_M], fields: set[str]) -> None: """Create an instance and add it to the registry.""" obj = cls(model, fields) for config in obj.config_fields: cls.config_registry.setdefault(config, set()).add(obj) cls.registry[obj.name] = obj - def reparse(self, config: Optional[str] = None, obj=None): + def reparse(self, config: str | None = None, obj: _M | None = None) -> None: """Reparse Markdown fields, optionally for a single config profile.""" if config and config not in self.config_fields: return @@ -45,17 +47,19 @@ def reparse(self, config: Optional[str] = None, obj=None): else: fields = self.fields + iter_list: Iterable[_M] + if obj is not None: iter_list = [obj] iter_total = 1 else: load_columns = ( - ['id'] - + [f'{field}_text'.lstrip('_') for field in fields] - + [f'{field}_html'.lstrip('_') for field in fields] + [self.model.id] + + [getattr(self.model, f'{field}_text'.lstrip('_')) for field in fields] + + [getattr(self.model, f'{field}_html'.lstrip('_')) for field in fields] ) iter_list = ( - self.model.query.order_by('id') + self.model.query.order_by(self.model.id) .options(sa.orm.load_only(*load_columns)) .yield_per(10) ) @@ -78,7 +82,7 @@ def reparse(self, config: Optional[str] = None, obj=None): MarkdownModel.register(models.Comment, {'_message'}) -MarkdownModel.register(models.Profile, {'description'}) +MarkdownModel.register(models.Account, {'description'}) MarkdownModel.register(models.Project, {'description', 'instructions'}) MarkdownModel.register(models.Proposal, {'body'}) MarkdownModel.register(models.Session, {'description'}) @@ -88,7 +92,9 @@ def reparse(self, config: Optional[str] = None, obj=None): @refresh.command('markdown') -@click.argument('content', type=click.Choice(MarkdownModel.registry.keys()), nargs=-1) +@click.argument( + 'content', type=click.Choice(list(MarkdownModel.registry.keys())), nargs=-1 +) @click.option( '-a', '--all', @@ -99,7 +105,7 @@ def reparse(self, config: Optional[str] = None, obj=None): @click.option( '-c', '--config', - type=click.Choice(MarkdownModel.config_registry.keys()), + type=click.Choice(list(MarkdownModel.config_registry.keys())), help="Reparse Markdown content using a specific configuration.", ) @click.option( @@ -108,7 +114,7 @@ def reparse(self, config: Optional[str] = None, obj=None): help="Reparse content at this URL", ) def markdown( - content: List[str], config: Optional[str], allcontent: bool, url: Optional[str] + content: list[str], config: str | None, allcontent: bool, url: str | None ) -> None: """Reparse Markdown content.""" if allcontent: diff --git a/funnel/devtest.py b/funnel/devtest.py index 4867ce8b2..21cdb251f 100644 --- a/funnel/devtest.py +++ b/funnel/devtest.py @@ -2,33 +2,36 @@ from __future__ import annotations -from typing import Any, Callable, Dict, Iterable, NamedTuple, Optional, Tuple import atexit +import gc +import inspect import multiprocessing import os -import platform import signal import socket import time - -from sqlalchemy.engine import Engine +import weakref +from collections.abc import Callable, Iterable +from secrets import token_urlsafe +from typing import Any, NamedTuple +from typing_extensions import Protocol from flask import Flask -from . import app as main_app -from . import shortlinkapp +from . import app as main_app, shortlinkapp, transports, unsubscribeapp from .models import db from .typing import ReturnView __all__ = ['AppByHostWsgi', 'BackgroundWorker', 'devtest_app'] -# Force 'fork' on macOS. The default mode of 'spawn' (from py38) causes a pickling -# error in py39, as reported in pytest-flask: -# https://github.com/pytest-dev/pytest-flask/pull/138 -# https://github.com/pytest-dev/pytest-flask/issues/139 -if platform.system() == 'Darwin': - os.environ['OBJC_DISABLE_INITIALIZE_FORK_SAFETY'] = 'YES' - multiprocessing = multiprocessing.get_context('fork') # type: ignore[assignment] +# Devtest requires `fork`. The default `spawn` method on macOS and Windows will +# cause pickling errors all over. `fork` is unavailable on Windows, so +# :class:`BackgroundWorker` can't be used there either, affecting `devserver.py` and the +# Pytest `live_server` fixture used for end-to-end tests. Fork on macOS is not +# compatible with the Objective C framework. If you have a framework Python build and +# experience crashes, try setting the environment variable +# OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES +mpcontext = multiprocessing.get_context('fork') # --- Development and testing app multiplexer ------------------------------------------ @@ -64,7 +67,7 @@ def __init__(self, *apps: Flask) -> None: for app in apps: if not app.config.get('SERVER_NAME'): raise ValueError(f"App does not have SERVER_NAME set: {app!r}") - self.apps_by_host: Dict[str, Flask] = { + self.apps_by_host: dict[str, Flask] = { app.config['SERVER_NAME'].split(':', 1)[0]: app for app in apps } @@ -92,12 +95,12 @@ def get_app(self, host: str) -> Flask: # If no host matched, use the info app return info_app - def __call__(self, environ, start_response) -> Iterable[bytes]: + def __call__(self, environ: Any, start_response: Any) -> Iterable[bytes]: use_app = self.get_app(environ['HTTP_HOST']) return use_app(environ, start_response) -devtest_app = AppByHostWsgi(main_app, shortlinkapp) +devtest_app = AppByHostWsgi(main_app, shortlinkapp, unsubscribeapp) # --- Background worker ---------------------------------------------------------------- @@ -109,16 +112,122 @@ class HostPort(NamedTuple): port: int -def _dispose_engines_in_child_process( - engines: Iterable[Engine], +class CapturedSms(NamedTuple): + phone: str + message: str + vars: dict[str, str] # noqa: A003 + + +class CapturedEmail(NamedTuple): + subject: str + to: list[str] + content: str + from_email: str | None + + +class CapturedCalls(Protocol): + """Protocol class for captured calls.""" + + email: list[CapturedEmail] + sms: list[CapturedSms] + + +def _signature_without_annotations(func) -> inspect.Signature: + """Generate a function signature without parameter type annotations.""" + sig = inspect.signature(func) + return sig.replace( + parameters=[ + p.replace(annotation=inspect.Parameter.empty) + for p in sig.parameters.values() + ] + ) + + +def install_mock(func: Callable, mock: Callable) -> None: + """ + Patch all existing references to :attr:`func` with :attr:`mock`. + + Uses the Python garbage collector to find and replace all references. + """ + # Validate function signature match before patching, ignoring type annotations + fsig = _signature_without_annotations(func) + msig = _signature_without_annotations(mock) + if fsig != msig: + raise TypeError( + f"Mock function’s signature does not match original’s:\n" + f"{mock.__name__}{msig} !=\n" + f"{func.__name__}{fsig}" + ) + # Use weakref to dereference func from local namespace + func = weakref.ref(func) + gc.collect() + refs = gc.get_referrers(func()) # type: ignore[misc] + # Recover func from the weakref so we can do an `is` match in referrers + func = func() # type: ignore[misc] + for ref in refs: + if isinstance(ref, dict): + # We have a namespace dict. Iterate through contents to find the reference + # and replace it + for key, value in ref.items(): + if value is func: + ref[key] = mock + + +def _prepare_subprocess( + mock_transports: bool, + calls: CapturedCalls, worker: Callable, - args: Tuple[Any], - kwargs: Dict[str, Any], + args: tuple[Any], + kwargs: dict[str, Any], ) -> Any: - """Dispose SQLAlchemy engine connections in a forked process.""" - # https://docs.sqlalchemy.org/en/14/core/pooling.html#pooling-multiprocessing - for e in engines: - e.dispose(close=False) # type: ignore[call-arg] + """ + Prepare a subprocess for hosting a worker. + + 1. Dispose all SQLAlchemy engine connections so they're not shared with the parent + 2. Mock transports if requested, redirecting all calls to a log + 3. Launch the worker + """ + # https://docs.sqlalchemy.org/en/20/core/pooling.html#pooling-multiprocessing + with main_app.app_context(): + for engine in db.engines.values(): + engine.dispose(close=False) + + if mock_transports: + + def mock_email( + subject: str, + to: list[Any], + content: str, + attachments=None, + from_email: Any | None = None, + headers: dict | None = None, + base_url: str | None = None, + ) -> str: + capture = CapturedEmail( + subject, + [str(each) for each in to], + content, + str(from_email) if from_email else None, + ) + calls.email.append(capture) + main_app.logger.info(capture) + return token_urlsafe() + + def mock_sms( + phone: Any, + message: transports.sms.SmsTemplate, + callback: bool = True, + ) -> str: + capture = CapturedSms(str(phone), str(message), message.vars()) + calls.sms.append(capture) + main_app.logger.info(capture) + return token_urlsafe() + + # Patch email + install_mock(transports.email.send.send_email, mock_email) + # Patch SMS + install_mock(transports.sms.send.send_sms, mock_sms) + return worker(*args, **kwargs) @@ -130,21 +239,23 @@ class BackgroundWorker: :param worker: The worker to run :param args: Args for worker :param kwargs: Kwargs for worker - :param probe: Optional host and port to probe for ready state + :param probe_at: Optional tuple of (host, port) to probe for ready state :param timeout: Timeout after which launch is considered to have failed :param clean_stop: Ask for graceful shutdown (default yes) :param daemon: Run process in daemon mode (linked to parent, automatic shutdown) + :param mock_transports: Patch transports with mock functions that write to a log """ - def __init__( # pylint: disable=too-many-arguments + def __init__( self, worker: Callable, - args: Optional[Iterable] = None, - kwargs: Optional[dict] = None, - probe_at: Optional[Tuple[str, int]] = None, + args: Iterable | None = None, + kwargs: dict | None = None, + probe_at: tuple[str, int] | None = None, timeout: int = 10, - clean_stop=True, - daemon=True, + clean_stop: bool = True, + daemon: bool = True, + mock_transports: bool = False, ) -> None: self.worker = worker self.worker_args = args or () @@ -153,22 +264,28 @@ def __init__( # pylint: disable=too-many-arguments self.timeout = timeout self.clean_stop = clean_stop self.daemon = daemon - self._process: Optional[multiprocessing.Process] = None + self._process: multiprocessing.context.ForkProcess | None = None + self.mock_transports = mock_transports + + manager = mpcontext.Manager() + self.calls: CapturedCalls = manager.Namespace() + self.calls.email = manager.list() + self.calls.sms = manager.list() def start(self) -> None: """Start worker in a separate process.""" if self._process is not None: return - engines = set() - for app in main_app, shortlinkapp: # TODO: Add hasjobapp here - with app.app_context(): - engines.add(db.engines[None]) - for bind in app.config.get('SQLALCHEMY_BINDS') or (): - engines.add(db.engines[bind]) - self._process = multiprocessing.Process( - target=_dispose_engines_in_child_process, - args=(engines, self.worker, self.worker_args, self.worker_kwargs), + self._process = mpcontext.Process( + target=_prepare_subprocess, + args=( + self.mock_transports, + self.calls, + self.worker, + self.worker_args, + self.worker_kwargs, + ), ) self._process.daemon = self.daemon self._process.start() @@ -206,7 +323,7 @@ def _is_ready(self) -> bool: return ret @property - def pid(self) -> Optional[int]: + def pid(self) -> int | None: """PID of background worker.""" return self._process.pid if self._process else None @@ -226,7 +343,7 @@ def _stop_cleanly(self) -> bool: """ Attempt to stop the server cleanly. - Sends a SIGINT signal and waits for ``timeout`` seconds. + Sends a SIGINT signal and waits for :attr:`timeout` seconds. :return: True if the server was cleanly stopped, False otherwise. """ @@ -249,3 +366,12 @@ def __repr__(self) -> str: f" {self.probe_at.host}:{self.probe_at.port}>" ) return f"" + + def __enter__(self) -> BackgroundWorker: + """Start server in a context manager.""" + self.start() + return self + + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + """Finalise a context manager.""" + self.stop() diff --git a/funnel/extapi/boxoffice.py b/funnel/extapi/boxoffice.py index 1f3300e55..7906b966a 100644 --- a/funnel/extapi/boxoffice.py +++ b/funnel/extapi/boxoffice.py @@ -4,9 +4,8 @@ from urllib.parse import urljoin -from flask import current_app - import requests +from flask import current_app from ..utils import extract_twitter_handle diff --git a/funnel/forms/account.py b/funnel/forms/account.py index d7b495b8c..027368c5d 100644 --- a/funnel/forms/account.py +++ b/funnel/forms/account.py @@ -1,13 +1,14 @@ -"""Forms for user account settings.""" +"""Forms for account settings.""" from __future__ import annotations +from collections.abc import Iterable from hashlib import sha1 -from typing import Dict, Iterable, Optional - -from flask_babel import ngettext import requests +from flask import url_for +from flask_babel import ngettext +from markupsafe import Markup from baseframe import _, __, forms from coaster.utils import sorted_timezones @@ -16,10 +17,8 @@ MODERATOR_REPORT_TYPE, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, + Account, Anchor, - Profile, - User, - UserEmailClaim, check_password_strength, getuser, ) @@ -83,13 +82,13 @@ def __call__(self, form, field) -> None: if form.edit_user.fullname: user_inputs.append(form.edit_user.fullname) - for useremail in form.edit_user.emails: - user_inputs.append(str(useremail)) + for accountemail in form.edit_user.emails: + user_inputs.append(str(accountemail)) for emailclaim in form.edit_user.emailclaims: user_inputs.append(str(emailclaim)) - for userphone in form.edit_user.phones: - user_inputs.append(str(userphone)) + for accountphone in form.edit_user.phones: + user_inputs.append(str(accountphone)) tested_password = check_password_strength( field.data, user_inputs=user_inputs if user_inputs else None @@ -112,8 +111,7 @@ def __call__(self, form, field) -> None: def pwned_password_validator(_form, field) -> None: """Validate password against the pwned password API.""" - # Add usedforsecurity=False when migrating to Python 3.9+ - phash = sha1(field.data.encode()).hexdigest().upper() # nosec + phash = sha1(field.data.encode(), usedforsecurity=False).hexdigest().upper() prefix, suffix = phash[:5], phash[5:] try: @@ -127,7 +125,7 @@ def pwned_password_validator(_form, field) -> None: # 2. Strip text on either side of the colon # 3. Ensure the suffix is uppercase # 4. If count is not a number, default it to 0 (ie, this is not a match) - matches: Dict[str, int] = { + matches: dict[str, int] = { line_suffix.upper(): int(line_count) if line_count.isdigit() else 0 for line_suffix, line_count in ( (split1.strip(), split2.strip()) @@ -155,12 +153,12 @@ def pwned_password_validator(_form, field) -> None: ) -@User.forms('password') +@Account.forms('password') class PasswordForm(forms.Form): """Form to validate a user's password, for password-gated sudo actions.""" __expects__ = ('edit_user',) - edit_user: User + edit_user: Account password = forms.PasswordField( __("Password"), @@ -171,22 +169,22 @@ class PasswordForm(forms.Form): render_kw={'autocomplete': 'current-password'}, ) - def validate_password(self, field) -> None: + def validate_password(self, field: forms.Field) -> None: """Check for password match.""" if not self.edit_user.password_is(field.data): raise forms.validators.ValidationError(_("Incorrect password")) -@User.forms('password_policy') +@Account.forms('password_policy') class PasswordPolicyForm(forms.Form): """Form to validate any candidate password against policy.""" __expects__ = ('edit_user',) __returns__ = ('password_strength', 'is_weak', 'warning', 'suggestions') - edit_user: User - password_strength: Optional[int] = None - is_weak: Optional[bool] = None - warning: Optional[str] = None + edit_user: Account + password_strength: int | None = None + is_weak: bool | None = None + warning: str | None = None suggestions: Iterable[str] = () password = forms.PasswordField( @@ -197,7 +195,7 @@ class PasswordPolicyForm(forms.Form): ], ) - def validate_password(self, field) -> None: + def validate_password(self, field: forms.Field) -> None: """Test password strength and save resuls (no errors raised).""" user_inputs = [] @@ -205,13 +203,13 @@ def validate_password(self, field) -> None: if self.edit_user.fullname: user_inputs.append(self.edit_user.fullname) - for useremail in self.edit_user.emails: - user_inputs.append(str(useremail)) + for accountemail in self.edit_user.emails: + user_inputs.append(str(accountemail)) for emailclaim in self.edit_user.emailclaims: user_inputs.append(str(emailclaim)) - for userphone in self.edit_user.phones: - user_inputs.append(str(userphone)) + for accountphone in self.edit_user.phones: + user_inputs.append(str(accountphone)) tested_password = check_password_strength( field.data, user_inputs=user_inputs if user_inputs else None @@ -222,13 +220,13 @@ def validate_password(self, field) -> None: self.suggestions = tested_password.suggestions -@User.forms('password_reset_request') +@Account.forms('password_reset_request') class PasswordResetRequestForm(forms.Form): """Form to request a password reset.""" __returns__ = ('user', 'anchor') - user: Optional[User] = None - anchor: Optional[Anchor] = None + user: Account | None = None + anchor: Anchor | None = None username = forms.StringField( __("Phone number or email address"), @@ -239,7 +237,7 @@ class PasswordResetRequestForm(forms.Form): }, ) - def validate_username(self, field) -> None: + def validate_username(self, field: forms.Field) -> None: """Process username to retrieve user.""" self.user, self.anchor = getuser(field.data, True) if self.user is None: @@ -248,14 +246,14 @@ def validate_username(self, field) -> None: ) -@User.forms('password_create') +@Account.forms('password_create') class PasswordCreateForm(forms.Form): """Form to accept a new password for a given user, without existing password.""" __returns__ = ('password_strength',) __expects__ = ('edit_user',) - edit_user: User - password_strength: Optional[int] = None + edit_user: Account + password_strength: int | None = None password = forms.PasswordField( __("New password"), @@ -278,12 +276,12 @@ class PasswordCreateForm(forms.Form): ) -@User.forms('password_reset') +@Account.forms('password_reset') class PasswordResetForm(forms.Form): """Form to reset a password for a user, requiring the user id as a failsafe.""" __returns__ = ('password_strength',) - password_strength: Optional[int] = None + password_strength: int | None = None # TODO: This form has been deprecated with OTP-based reset as that doesn't need # username and now uses :class:`PasswordCreateForm`. This form is retained in the @@ -321,7 +319,7 @@ class PasswordResetForm(forms.Form): render_kw={'autocomplete': 'new-password'}, ) - def validate_username(self, field) -> None: + def validate_username(self, field: forms.Field) -> None: """Confirm the user provided by the client is who this form is meant for.""" user = getuser(field.data) if user is None or user != self.edit_user: @@ -330,14 +328,14 @@ def validate_username(self, field) -> None: ) -@User.forms('password_change') +@Account.forms('password_change') class PasswordChangeForm(forms.Form): """Form to change a user's password after confirming the old password.""" __returns__ = ('password_strength',) __expects__ = ('edit_user',) - edit_user: User - password_strength: Optional[int] = None + edit_user: Account + password_strength: int | None = None old_password = forms.PasswordField( __("Current password"), @@ -367,7 +365,7 @@ class PasswordChangeForm(forms.Form): render_kw={'autocomplete': 'new-password'}, ) - def validate_old_password(self, field) -> None: + def validate_old_password(self, field: forms.Field) -> None: """Validate the old password to be correct.""" if self.edit_user is None: raise forms.validators.ValidationError(_("Not logged in")) @@ -383,10 +381,7 @@ def raise_username_error(reason: str) -> str: raise forms.validators.ValidationError(_("This is too long")) if reason == 'invalid': raise forms.validators.ValidationError( - _( - "Usernames can only have alphabets, numbers and dashes (except at the" - " ends)" - ) + _("Usernames can only have alphabets, numbers and underscores") ) if reason == 'reserved': raise forms.validators.ValidationError(_("This username is reserved")) @@ -395,18 +390,18 @@ def raise_username_error(reason: str) -> str: raise forms.validators.ValidationError(_("This username is not available")) -@User.forms('main') +@Account.forms('main') class AccountForm(forms.Form): """Form to edit basic account details.""" - edit_obj: User + edit_obj: Account fullname = forms.StringField( __("Full name"), description=__("This is your name, not of your organization"), validators=[ forms.validators.DataRequired(), - forms.validators.Length(max=User.__title_length__), + forms.validators.Length(max=Account.__title_length__), ], filters=[forms.filters.strip()], render_kw={'autocomplete': 'name'}, @@ -414,12 +409,11 @@ class AccountForm(forms.Form): username = forms.AnnotatedTextField( __("Username"), description=__( - "Single word that can contain letters, numbers and dashes." - " You need a username to have a public account page" + "A single word that is uniquely yours, for your account page and @mentions" ), validators=[ forms.validators.DataRequired(), - forms.validators.Length(max=Profile.__name_length__), + forms.validators.Length(max=Account.__name_length__), ], filters=nullable_strip_filters, prefix="https://hasgeek.com/", @@ -446,17 +440,17 @@ class AccountForm(forms.Form): ) auto_locale = forms.BooleanField(__("Use your device’s language")) - def validate_username(self, field) -> None: + def validate_username(self, field: forms.Field) -> None: """Validate if username is appropriately formatted and available to use.""" - reason = self.edit_obj.validate_name_candidate(field.data) + reason = self.edit_obj.validate_new_name(field.data) if not reason: return # Username is available raise_username_error(reason) -@User.forms('delete') +@Account.forms('delete') class AccountDeleteForm(forms.Form): - """Delete user account.""" + """Delete account.""" confirm1 = forms.BooleanField( __( @@ -479,7 +473,7 @@ class UsernameAvailableForm(forms.Form): """Form to check for whether a username is available to use.""" __expects__ = ('edit_user',) - edit_user: User + edit_user: Account username = forms.StringField( __("Username"), @@ -491,38 +485,44 @@ class UsernameAvailableForm(forms.Form): }, ) - def validate_username(self, field) -> None: + def validate_username(self, field: forms.Field) -> None: """Validate for username being valid and available (with optionally user).""" if self.edit_user: # User is setting a username - reason = self.edit_user.validate_name_candidate(field.data) + reason = self.edit_user.validate_new_name(field.data) else: # New user is creating an account, so no user object yet - reason = Profile.validate_name_candidate(field.data) + reason = Account.validate_name_candidate(field.data) if not reason: return # Username is available raise_username_error(reason) -def validate_emailclaim(form, field): - """Validate if an email address is already pending verification.""" - existing = UserEmailClaim.get_for(user=form.edit_user, email=field.data) - if existing is not None: - raise forms.validators.StopValidation( - _("This email address is pending verification") - ) +class EnableNotificationsDescriptionMixin: + """Mixin to add a link in the description for enabling notifications.""" + + enable_notifications: forms.Field + + def set_queries(self) -> None: + """Change the description to include a link.""" + self.enable_notifications.description = Markup( + _( + "Unsubscribe anytime, and control what notifications are sent from the" + ' Notifications tab under account' + ' settings' + ) + ).format(url=url_for('notification_preferences')) -@User.forms('email_add') -class NewEmailAddressForm(forms.RecaptchaForm): - """Form to add a new email address to a user account.""" +@Account.forms('email_add') +class NewEmailAddressForm(EnableNotificationsDescriptionMixin, forms.RecaptchaForm): + """Form to add a new email address to an account.""" __expects__ = ('edit_user',) - edit_user: User + edit_user: Account email = forms.EmailField( __("Email address"), validators=[ forms.validators.DataRequired(), - validate_emailclaim, EmailAddressAvailable(purpose='claim'), ], filters=strip_filters, @@ -532,19 +532,18 @@ class NewEmailAddressForm(forms.RecaptchaForm): 'autocomplete': 'email', }, ) - type = forms.RadioField( # noqa: A003 - __("Type"), - validators=[forms.validators.Optional()], - filters=[forms.filters.strip()], - choices=[ - (__("Home"), __("Home")), - (__("Work"), __("Work")), - (__("Other"), __("Other")), - ], + + enable_notifications = forms.BooleanField( + __("Send notifications by email"), + description=__( + "Unsubscribe anytime, and control what notifications are sent from the" + " Notifications tab under account settings" + ), + default=True, ) -@User.forms('email_primary') +@Account.forms('email_primary') class EmailPrimaryForm(forms.Form): """Form to mark an email address as a user's primary.""" @@ -560,12 +559,12 @@ class EmailPrimaryForm(forms.Form): ) -@User.forms('phone_add') -class NewPhoneForm(forms.RecaptchaForm): - """Form to add a new mobile number (SMS-capable) to a user account.""" +@Account.forms('phone_add') +class NewPhoneForm(EnableNotificationsDescriptionMixin, forms.RecaptchaForm): + """Form to add a new mobile number (SMS-capable) to an account.""" __expects__ = ('edit_user',) - edit_user: User + edit_user: Account phone = forms.TelField( __("Phone number"), @@ -578,8 +577,10 @@ class NewPhoneForm(forms.RecaptchaForm): render_kw={'autocomplete': 'tel'}, ) + # TODO: Consider option "prefer WhatsApp" or "prefer secure messengers (WhatsApp)" + enable_notifications = forms.BooleanField( - __("Send notifications by SMS"), + __("Send notifications by SMS"), # TODO: Add "or WhatsApp" description=__( "Unsubscribe anytime, and control what notifications are sent from the" " Notifications tab under account settings" @@ -588,7 +589,7 @@ class NewPhoneForm(forms.RecaptchaForm): ) -@User.forms('phone_primary') +@Account.forms('phone_primary') class PhonePrimaryForm(forms.Form): """Form to mark a phone number as a user's primary.""" diff --git a/funnel/forms/auth_client.py b/funnel/forms/auth_client.py index 549052c5f..bc2b5db87 100644 --- a/funnel/forms/auth_client.py +++ b/funnel/forms/auth_client.py @@ -2,20 +2,16 @@ from __future__ import annotations -from typing import Optional from urllib.parse import urlparse from baseframe import _, __, forms from coaster.utils import getbool from ..models import ( + Account, AuthClient, AuthClientCredential, - AuthClientTeamPermissions, - AuthClientUserPermissions, - Organization, - Team, - User, + AuthClientPermissions, valid_name, ) from .helpers import strip_filters @@ -24,7 +20,6 @@ 'AuthClientForm', 'AuthClientCredentialForm', 'AuthClientPermissionEditForm', - 'TeamPermissionAssignForm', 'UserPermissionAssignForm', ] @@ -33,9 +28,8 @@ class AuthClientForm(forms.Form): """Register a new OAuth client application.""" - __returns__ = ('user', 'organization') - user: Optional[User] = None - organization: Optional[Organization] = None + __returns__ = ('account',) + account: Account | None = None title = forms.StringField( __("Application title"), @@ -52,8 +46,8 @@ class AuthClientForm(forms.Form): __("Owner"), validators=[forms.validators.DataRequired()], description=__( - "User or organization that owns this application. Changing the owner" - " will revoke all currently assigned permissions for this app" + "Account that owns this application. Changing the owner will revoke all" + " currently assigned permissions for this app" ), ) confidential = forms.RadioField( @@ -105,11 +99,10 @@ class AuthClientForm(forms.Form): ), ) - def validate_client_owner(self, field) -> None: + def validate_client_owner(self, field: forms.Field) -> None: """Validate client's owner to be the current user or an org owned by them.""" if field.data == self.edit_user.buid: - self.user = self.edit_user - self.organization = None + self.account = self.edit_user else: orgs = [ org @@ -118,8 +111,7 @@ def validate_client_owner(self, field) -> None: ] if len(orgs) != 1: raise forms.validators.ValidationError(_("Invalid owner")) - self.user = None - self.organization = orgs[0] + self.account = orgs[0] def _urls_match(self, url1: str, url2: str) -> bool: """Validate two URLs have the same base component (minus path).""" @@ -132,7 +124,7 @@ def _urls_match(self, url1: str, url2: str) -> bool: and (p1.password == p2.password) ) - def validate_redirect_uri(self, field) -> None: + def validate_redirect_uri(self, field: forms.Field) -> None: """Validate redirect URI points to the website for confidential clients.""" if self.confidential.data and not self._urls_match( self.website.data, field.data @@ -156,7 +148,7 @@ class AuthClientCredentialForm(forms.Form): ) -def permission_validator(form, field) -> None: +def permission_validator(form: forms.Form, field: forms.Field) -> None: """Validate permission strings to be appropriately named.""" permlist = field.data.split() for perm in permlist: @@ -169,7 +161,7 @@ def permission_validator(form, field) -> None: @AuthClient.forms('permissions_user') -@AuthClientUserPermissions.forms('assign') +@AuthClientPermissions.forms('assign') class UserPermissionAssignForm(forms.Form): """Assign permissions to a user.""" @@ -184,35 +176,7 @@ class UserPermissionAssignForm(forms.Form): ) -@AuthClient.forms('permissions_team') -@AuthClientTeamPermissions.forms('assign') -class TeamPermissionAssignForm(forms.Form): - """Assign permissions to a team.""" - - __returns__ = ('team',) - team: Optional[Team] = None - - team_id = forms.RadioField( - __("Team"), - validators=[forms.validators.DataRequired()], - description=__("Select a team to assign permissions to"), - ) - perms = forms.StringField( - __("Permissions"), - validators=[forms.validators.DataRequired(), permission_validator], - ) - - def validate_team_id(self, field) -> None: - """Validate selected team to belong to this organization.""" - # FIXME: Replace with QuerySelectField using RadioWidget. - teams = [team for team in self.organization.teams if team.buid == field.data] - if len(teams) != 1: - raise forms.validators.ValidationError(_("Unknown team")) - self.team = teams[0] - - -@AuthClientUserPermissions.forms('edit') -@AuthClientTeamPermissions.forms('edit') +@AuthClientPermissions.forms('edit') class AuthClientPermissionEditForm(forms.Form): """Edit a user or team's permissions.""" diff --git a/funnel/forms/comment.py b/funnel/forms/comment.py index 3745f2acc..8fcc0f3b2 100644 --- a/funnel/forms/comment.py +++ b/funnel/forms/comment.py @@ -15,7 +15,7 @@ class CommentForm(forms.Form): message = forms.MarkdownField( "", - id="comment_message", + id='comment_message', validators=[forms.validators.DataRequired()], ) diff --git a/funnel/forms/helpers.py b/funnel/forms/helpers.py index 56fac24b8..1a696c4f2 100644 --- a/funnel/forms/helpers.py +++ b/funnel/forms/helpers.py @@ -2,49 +2,63 @@ from __future__ import annotations -from typing import Optional +import json +from collections.abc import Sequence +from typing import Literal from flask import flash -from typing_extensions import Literal - from baseframe import _, __, forms from coaster.auth import current_auth from .. import app from ..models import ( + Account, + AccountEmailClaim, EmailAddress, PhoneNumber, - Profile, - UserEmailClaim, + User, canonical_phone_number, parse_phone_number, parse_video_url, ) +# --- Error messages ------------------------------------------------------------------- + +MSG_EMAIL_INVALID = _("This does not appear to be a valid email address") +MSG_EMAIL_BLOCKED = __("This email address has been blocked from use") +MSG_INCORRECT_PASSWORD = __("Incorrect password") +MSG_NO_ACCOUNT = __( + "This account could not be identified. Try with a phone number or email address" +) +MSG_INCORRECT_OTP = __("OTP is incorrect") +MSG_NO_LOGIN_SESSION = __("That does not appear to be a valid login session") +MSG_PHONE_NO_SMS = __("This phone number cannot receive SMS messages") +MSG_PHONE_BLOCKED = __("This phone number has been blocked from use") + -class ProfileSelectField(forms.AutocompleteField): +class AccountSelectField(forms.AutocompleteField): """Render an autocomplete field for selecting an account.""" - data: Optional[Profile] + data: Account | None # type: ignore[assignment] widget = forms.Select2Widget() multiple = False widget_autocomplete = True - def _value(self): + def _value(self) -> str: """Return value for HTML rendering.""" - if self.data: + if self.data is not None: return self.data.name return '' - def process_formdata(self, valuelist) -> None: + def process_formdata(self, valuelist: Sequence[str]) -> None: """Process incoming form data.""" if valuelist: - self.data = Profile.query.filter( + self.data = Account.query.filter( # Limit to non-suspended (active) accounts. Do not require account to # be public as well - Profile.name == valuelist[0], - Profile.is_active, + Account.name_is(valuelist[0]), + Account.state.ACTIVE, ).one_or_none() else: self.data = None @@ -67,24 +81,26 @@ def __init__(self, purpose: Literal['use', 'claim', 'register']) -> None: raise ValueError("Invalid purpose") self.purpose = purpose - def __call__(self, form, field) -> None: - # Get actor (from existing obj, or current_auth.actor) - actor = None - if hasattr(form, 'edit_obj'): - obj = form.edit_obj - if obj and hasattr(obj, '__email_for__'): - actor = getattr(obj, obj.__email_for__) + def __call__(self, form: forms.Form, field: forms.Field) -> None: + # Get actor (from form, or current_auth.actor) + actor: User | None = None + if hasattr(form, 'edit_user'): + actor = form.edit_user if actor is None: actor = current_auth.actor # Call validator - is_valid = EmailAddress.validate_for( + has_error = EmailAddress.validate_for( actor, field.data, check_dns=True, new=self.purpose != 'use' ) # Interpret code - if not is_valid: + if has_error == 'taken': if actor is not None: + if self.purpose == 'claim': + # Allow a claim on an existing ownership -- if verified, it will + # lead to account merger + return raise forms.validators.StopValidation( _("This email address is linked to another account") ) @@ -94,11 +110,9 @@ def __call__(self, form, field) -> None: " logging in or resetting your password" ) ) - if is_valid in ('invalid', 'nullmx'): - raise forms.validators.StopValidation( - _("This does not appear to be a valid email address") - ) - if is_valid == 'nomx': + if has_error in ('invalid', 'nullmx'): + raise forms.validators.StopValidation(MSG_EMAIL_INVALID) + if has_error == 'nomx': raise forms.validators.StopValidation( _( "The domain name of this email address is missing a DNS MX record." @@ -106,11 +120,11 @@ def __call__(self, form, field) -> None: " spam. Please ask your tech person to add MX to DNS" ) ) - if is_valid == 'not_new': + if has_error == 'not_new': raise forms.validators.StopValidation( _("You have already registered this email address") ) - if is_valid == 'soft_fail': + if has_error == 'soft_fail': # XXX: In the absence of support for warnings in WTForms, we can only use # flash messages to communicate flash( @@ -121,26 +135,22 @@ def __call__(self, form, field) -> None: 'warning', ) return - if is_valid == 'hard_fail': + if has_error == 'hard_fail': raise forms.validators.StopValidation( _( "This email address is no longer valid. If you believe this to be" " incorrect, email {support} asking for the address to be activated" ).format(support=app.config['SITE_SUPPORT_EMAIL']) ) - if is_valid == 'blocked': - raise forms.validators.StopValidation( - _("This email address has been blocked from use") - ) - if is_valid is not True: - app.logger.error( # type: ignore[unreachable] - "Unknown email address validation code: %r", is_valid - ) + if has_error == 'blocked': + raise forms.validators.StopValidation(MSG_EMAIL_BLOCKED) + if has_error is not None: + app.logger.error("Unknown email address validation code: %r", has_error) - if is_valid and self.purpose == 'register': + if has_error is None and self.purpose == 'register': # One last check: is there an existing claim? If so, stop the user from # making a dupe account - if UserEmailClaim.all(email=field.data).notempty(): + if AccountEmailClaim.all(email=field.data).notempty(): raise forms.validators.StopValidation( _( "You or someone else has made an account with this email" @@ -162,13 +172,11 @@ def __init__(self, purpose: Literal['use', 'claim', 'register']) -> None: raise ValueError("Invalid purpose") self.purpose = purpose - def __call__(self, form, field) -> None: + def __call__(self, form: forms.Form, field: forms.Field) -> None: # Get actor (from existing obj, or current_auth.actor) - actor = None - if hasattr(form, 'edit_obj'): - obj = form.edit_obj - if obj and hasattr(obj, '__phone_for__'): - actor = getattr(obj, obj.__phone_for__) + actor: User | None = None + if hasattr(form, 'edit_user'): + actor = form.edit_user if actor is None: actor = current_auth.actor @@ -181,14 +189,21 @@ def __call__(self, form, field) -> None: raise forms.validators.StopValidation( _("This does not appear to be a valid phone number") ) + + # Save the parsed number back to the form field + field.data = canonical_phone_number(parsed_number) # Call validator - is_valid = PhoneNumber.validate_for( + has_error = PhoneNumber.validate_for( actor, parsed_number, new=self.purpose != 'use' ) # Interpret code - if not is_valid: + if has_error == 'taken': if actor is not None: + if self.purpose == 'claim': + # Allow a claim on an existing ownership -- if verified, it will + # lead to account merger. + return raise forms.validators.StopValidation( _("This phone number is linked to another account") ) @@ -198,41 +213,55 @@ def __call__(self, form, field) -> None: " logging in or resetting your password" ) ) - if is_valid == 'invalid': + if has_error == 'invalid': raise forms.validators.StopValidation( _("This does not appear to be a valid phone number") ) - if is_valid == 'not_new': + if has_error == 'not_new': raise forms.validators.StopValidation( _("You have already registered this phone number") ) - if is_valid == 'blocked': + if has_error == 'blocked': raise forms.validators.StopValidation( _("This phone number has been blocked from use") ) - if is_valid is not True: + if has_error is not None: app.logger.error( # type: ignore[unreachable] - "Unknown phone number validation code: %r", is_valid + "Unknown phone number validation code: %r", has_error ) - field.data = canonical_phone_number(parsed_number) -def image_url_validator(): +def image_url_validator() -> forms.validators.ValidUrl: """Customise ValidUrl for hosted image URL validation.""" return forms.validators.ValidUrl( allowed_schemes=lambda: app.config.get('IMAGE_URL_SCHEMES', ('https',)), - allowed_domains=lambda: app.config.get('IMAGE_URL_DOMAINS'), + allowed_domains=lambda: app.config.get( # type: ignore[arg-type, return-value] + 'IMAGE_URL_DOMAINS' + ), message_schemes=__("A https:// URL is required"), message_domains=__("Images must be hosted at images.hasgeek.com"), ) -def video_url_validator(form, field): +def video_url_list_validator(form: forms.Form, field: forms.Field) -> None: + """Validate all video URLs to be acceptable.""" + for url in field.data: + try: + parse_video_url(url) + except ValueError: + raise forms.validators.StopValidation( + _("This video URL is not supported") + ) from None + + +def video_url_validator(form: forms.Form, field: forms.Field) -> None: """Validate the video URL to be acceptable.""" try: parse_video_url(field.data) - except ValueError as exc: - raise forms.validators.StopValidation(str(exc)) + except ValueError: + raise forms.validators.StopValidation( + _("This video URL is not supported") + ) from None def tostr(value: object) -> str: @@ -242,5 +271,27 @@ def tostr(value: object) -> str: return '' +def format_json(data: dict | str | None) -> str: + """Return a dict as a formatted JSON string, and return a string unchanged.""" + if data: + if isinstance(data, str): + return data + return json.dumps(data, indent=2, sort_keys=True) + return '' + + +def validate_and_convert_json(form: forms.Form, field: forms.Field) -> None: + """Confirm form data is valid JSON, and store it back as a parsed dict.""" + try: + field.data = json.loads(field.data) + except ValueError: + raise forms.validators.StopValidation(_("Invalid JSON")) from None + + strip_filters = [tostr, forms.filters.strip()] nullable_strip_filters = [tostr, forms.filters.strip(), forms.filters.none_if_empty()] +nullable_json_filters = [ + format_json, + forms.filters.strip(), + forms.filters.none_if_empty(), +] diff --git a/funnel/forms/login.py b/funnel/forms/login.py index c518df1de..a4111157d 100644 --- a/funnel/forms/login.py +++ b/funnel/forms/login.py @@ -2,25 +2,36 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING -from baseframe import __, forms +from baseframe import _, __, forms from ..models import ( PASSWORD_MAX_LENGTH, + Account, + AccountEmail, + AccountEmailClaim, + AccountPhone, EmailAddress, EmailAddressBlockedError, + LoginSession, PhoneNumber, PhoneNumberBlockedError, User, - UserEmail, - UserEmailClaim, - UserPhone, - UserSession, check_password_strength, getuser, parse_phone_number, ) +from .helpers import ( + MSG_EMAIL_BLOCKED, + MSG_EMAIL_INVALID, + MSG_INCORRECT_OTP, + MSG_INCORRECT_PASSWORD, + MSG_NO_ACCOUNT, + MSG_NO_LOGIN_SESSION, + MSG_PHONE_BLOCKED, + MSG_PHONE_NO_SMS, +) __all__ = [ 'LoginPasswordResetException', @@ -30,20 +41,10 @@ 'LogoutForm', 'RegisterWithOtp', 'OtpForm', + 'EmailOtpForm', 'RegisterOtpForm', ] -# --- Error messages ------------------------------------------------------------------- - -MSG_EMAIL_BLOCKED = __("This email address has been blocked from use") -MSG_INCORRECT_PASSWORD = __("Incorrect password") -MSG_NO_ACCOUNT = __( - "This account could not be identified. Try with a phone number or email address" -) -MSG_INCORRECT_OTP = __("OTP is incorrect") -MSG_NO_LOGIN_SESSION = __("That does not appear to be a valid login session") -MSG_PHONE_NO_SMS = __("This phone number cannot receive SMS messages") -MSG_PHONE_BLOCKED = __("This phone number has been blocked from use") # --- Exceptions ----------------------------------------------------------------------- @@ -61,11 +62,12 @@ class LoginWithOtp(Exception): # noqa: N818 class RegisterWithOtp(Exception): # noqa: N818 - """Exception to signal for new user account registration after OTP validation.""" + """Exception to signal for new account registration after OTP validation.""" # --- Validators ----------------------------------------------------------------------- + # Validator specifically for LoginForm class PasswordlessLoginIntercept: """Allow password to be optional if an anchor (phone, email) is available.""" @@ -93,7 +95,7 @@ def __call__(self, form, field) -> None: # --- Forms ---------------------------------------------------------------------------- -@User.forms('login') +@Account.forms('login') class LoginForm(forms.RecaptchaForm): """ Form for login and registration. @@ -120,11 +122,11 @@ class LoginForm(forms.RecaptchaForm): """ __returns__ = ('user', 'anchor', 'weak_password', 'new_email', 'new_phone') - user: Optional[User] = None - anchor: Optional[Union[UserEmail, UserEmailClaim, UserPhone]] = None - weak_password: Optional[bool] = None - new_email: Optional[str] = None - new_phone: Optional[str] = None + user: Account | None = None + anchor: AccountEmail | AccountEmailClaim | AccountPhone | None = None + weak_password: bool | None = None + new_email: str | None = None + new_phone: str | None = None username = forms.StringField( __("Phone number or email address"), @@ -154,7 +156,7 @@ class LoginForm(forms.RecaptchaForm): ) # These two validators depend on being called in sequence - def validate_username(self, field) -> None: + def validate_username(self, field: forms.Field) -> None: """Process username field and load user and anchor.""" self.user, self.anchor = getuser(field.data, True) # skipcq: PYL-W0201 self.new_email = self.new_phone = None @@ -171,6 +173,8 @@ def validate_username(self, field) -> None: self.new_email = str(email_address) except EmailAddressBlockedError as exc: raise forms.validators.ValidationError(MSG_EMAIL_BLOCKED) from exc + except ValueError as exc: + raise forms.validators.StopValidation(MSG_EMAIL_INVALID) from exc return phone = parse_phone_number(field.data, sms=True) if phone is False: @@ -187,7 +191,7 @@ def validate_username(self, field) -> None: # Not a known user and not a valid email address or phone number -> error raise forms.validators.ValidationError(MSG_NO_ACCOUNT) - def validate_password(self, field) -> None: + def validate_password(self, field: forms.Field) -> None: """Validate password if provided.""" # If there is already an error in the password field, don't bother validating. # This will be a `Length` validation error, but that one unfortunately does not @@ -243,14 +247,14 @@ def validate_password(self, field) -> None: self.weak_password: bool = check_password_strength(field.data).is_weak -@User.forms('logout') +@Account.forms('logout') class LogoutForm(forms.Form): """Process a logout request.""" __expects__ = ('user',) - __returns__ = ('user_session',) - user: User - user_session: Optional[UserSession] = None + __returns__ = ('login_session',) + user: Account + login_session: LoginSession | None = None # We use `StringField`` even though the field is not visible. This does not use # `HiddenField`, because that gets rendered with `hidden_tag`, and not `SubmitField` @@ -259,12 +263,12 @@ class LogoutForm(forms.Form): __("Session id"), validators=[forms.validators.Optional()] ) - def validate_sessionid(self, field) -> None: + def validate_sessionid(self, field: forms.Field) -> None: """Validate login session belongs to the user who invoked this form.""" - user_session = UserSession.get(buid=field.data) - if not user_session or user_session.user != self.user: + login_session = LoginSession.get(buid=field.data) + if not login_session or login_session.account != self.user: raise forms.validators.ValidationError(MSG_NO_LOGIN_SESSION) - self.user_session = user_session + self.login_session = login_session class OtpForm(forms.Form): @@ -286,12 +290,20 @@ class OtpForm(forms.Form): }, ) - def validate_otp(self, field) -> None: + def validate_otp(self, field: forms.Field) -> None: """Confirm OTP is as expected.""" if field.data != self.valid_otp: raise forms.validators.StopValidation(MSG_INCORRECT_OTP) +class EmailOtpForm(OtpForm): + """Verify an OTP sent to email.""" + + def set_queries(self) -> None: + super().set_queries() + self.otp.description = _("One-time password sent to your email address") + + class RegisterOtpForm(forms.Form): """Verify an OTP and register an account.""" @@ -322,7 +334,7 @@ class RegisterOtpForm(forms.Form): }, ) - def validate_otp(self, field) -> None: + def validate_otp(self, field: forms.Field) -> None: """Confirm OTP is as expected.""" if field.data != self.valid_otp: raise forms.validators.StopValidation(MSG_INCORRECT_OTP) diff --git a/funnel/forms/membership.py b/funnel/forms/membership.py index a6c8af94f..7acd78465 100644 --- a/funnel/forms/membership.py +++ b/funnel/forms/membership.py @@ -5,7 +5,8 @@ from baseframe import _, __, forms from coaster.utils import getbool -from ..models import OrganizationMembership, ProjectCrewMembership +from ..models import AccountMembership, ProjectMembership +from .helpers import nullable_strip_filters __all__ = [ 'OrganizationMembershipForm', @@ -14,7 +15,7 @@ ] -@OrganizationMembership.forms('main') +@AccountMembership.forms('main') class OrganizationMembershipForm(forms.Form): """Form to add a member to an organization (admin or owner).""" @@ -37,7 +38,7 @@ class OrganizationMembershipForm(forms.Form): ) -@ProjectCrewMembership.forms('main') +@ProjectMembership.forms('main') class ProjectCrewMembershipForm(forms.Form): """Form to add a project crew member.""" @@ -65,17 +66,22 @@ class ProjectCrewMembershipForm(forms.Form): "Can check-in a participant using their badge at a physical event" ), ) + label = forms.StringField( + __("Role"), + description=__("Optional – Name this person’s role"), + filters=nullable_strip_filters, + ) - def validate(self, *args, **kwargs): + def validate(self, *args, **kwargs) -> bool: """Validate form.""" is_valid = super().validate(*args, **kwargs) if not any([self.is_editor.data, self.is_promoter.data, self.is_usher.data]): - self.is_usher.errors.append("Please select one or more roles") + self.is_usher.errors.append(_("Select one or more roles")) is_valid = False return is_valid -@ProjectCrewMembership.forms('invite') +@ProjectMembership.forms('invite') class ProjectCrewMembershipInviteForm(forms.Form): """Form to invite a user to be a project crew member.""" diff --git a/funnel/forms/notification.py b/funnel/forms/notification.py index bd68e6ece..bc8342fde 100644 --- a/funnel/forms/notification.py +++ b/funnel/forms/notification.py @@ -2,14 +2,15 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, List, Optional -from flask import Markup, url_for +from flask import url_for +from markupsafe import Markup from baseframe import __, forms -from ..models import User, notification_type_registry +from ..models import Account, notification_type_registry from ..transports import platform_transports __all__ = [ @@ -25,7 +26,7 @@ class TransportLabels: title: str requirement: str - requirement_action: Callable[[], Optional[str]] + requirement_action: Callable[[], str | None] unsubscribe_form: str unsubscribe_description: str switch: str @@ -115,12 +116,12 @@ class TransportLabels: } -@User.forms('unsubscribe') +@Account.forms('unsubscribe') class UnsubscribeForm(forms.Form): """Form to unsubscribe from notifications.""" __expects__ = ('transport', 'notification_type') - edit_obj: User + edit_obj: Account transport: str notification_type: str @@ -180,7 +181,7 @@ def get_main(self, obj) -> bool: """Get main preferences switch (global enable/disable).""" return obj.main_notification_preferences.by_transport(self.transport) - def get_types(self, obj) -> List[str]: + def get_types(self, obj) -> list[str]: """Get status for each notification type for the selected transport.""" # Populate data with all notification types for which the user has the # current transport enabled @@ -206,7 +207,7 @@ def set_types(self, obj) -> None: ) -@User.forms('set_notification_preference') +@Account.forms('set_notification_preference') class SetNotificationPreferenceForm(forms.Form): """Set one notification preference.""" diff --git a/funnel/forms/organization.py b/funnel/forms/organization.py index 01a74b5f5..077b96454 100644 --- a/funnel/forms/organization.py +++ b/funnel/forms/organization.py @@ -2,24 +2,25 @@ from __future__ import annotations -from typing import Iterable, Optional +from collections.abc import Iterable -from flask import Markup, url_for +from flask import url_for +from markupsafe import Markup from baseframe import _, __, forms -from ..models import Organization, Profile, Team, User +from ..models import Account, Team __all__ = ['OrganizationForm', 'TeamForm'] -@Organization.forms('main') +@Account.forms('org') class OrganizationForm(forms.Form): """Form for an organization's name and title.""" - __expects__: Iterable[str] = ('user',) - user: User - edit_obj: Optional[Organization] + __expects__: Iterable[str] = ('edit_user',) + edit_user: Account + edit_obj: Account | None title = forms.StringField( __("Organization name"), @@ -28,7 +29,7 @@ class OrganizationForm(forms.Form): ), validators=[ forms.validators.DataRequired(), - forms.validators.Length(max=Organization.__title_length__), + forms.validators.Length(max=Account.__title_length__), ], filters=[forms.filters.strip()], render_kw={'autocomplete': 'organization'}, @@ -36,40 +37,42 @@ class OrganizationForm(forms.Form): name = forms.AnnotatedTextField( __("Username"), description=__( - "A short name for your organization’s account page." - " Single word containing letters, numbers and dashes only." - " Pick something permanent: changing it will break existing links from" - " around the web" + "A unique word for your organization’s account page. Alphabets, numbers and" + " underscores are okay. Pick something permanent: changing it will break" + " links" ), validators=[ forms.validators.DataRequired(), - forms.validators.Length(max=Profile.__name_length__), + forms.validators.Length(max=Account.__name_length__), ], filters=[forms.filters.strip()], prefix="https://hasgeek.com/", render_kw={'autocorrect': 'off', 'autocapitalize': 'off'}, ) - def validate_name(self, field) -> None: + def validate_name(self, field: forms.Field) -> None: """Validate name is valid and available for this organization.""" - reason = Profile.validate_name_candidate(field.data) + reason = Account.validate_name_candidate(field.data) if not reason: return # name is available if reason == 'invalid': raise forms.validators.ValidationError( - _( - "Names can only have letters, numbers and dashes (except at the" - " ends)" - ) + _("Names can only have alphabets, numbers and underscores") ) if reason == 'reserved': raise forms.validators.ValidationError(_("This name is reserved")) - if self.edit_obj and field.data.lower() == self.edit_obj.name.lower(): - # Name is not reserved or invalid under current rules. It's also not changed - # from existing name, or has only changed case. This is a validation pass. + if ( + self.edit_obj + and self.edit_obj.name + and field.data.lower() == self.edit_obj.name.lower() + ): + # Name has only changed case from previous name. This is a validation pass return if reason == 'user': - if self.user.username and field.data.lower() == self.user.username.lower(): + if ( + self.edit_user.username + and field.data.lower() == self.edit_user.username.lower() + ): raise forms.validators.ValidationError( Markup( _( diff --git a/funnel/forms/profile.py b/funnel/forms/profile.py index b49e0f055..8236caf2a 100644 --- a/funnel/forms/profile.py +++ b/funnel/forms/profile.py @@ -4,7 +4,7 @@ from baseframe import __, forms -from ..models import Profile, User +from ..models import Account from .helpers import image_url_validator, nullable_strip_filters from .organization import OrganizationForm @@ -16,17 +16,16 @@ ] -@Profile.forms('main') +@Account.forms('profile') class ProfileForm(OrganizationForm): """ Edit a profile. - A `profile` keyword argument is necessary for the ImgeeField. + An `account` keyword argument is necessary for the ImgeeField. """ - __expects__ = ('profile', 'user') - profile: Profile - user: User + __expects__ = ('account', 'edit_user') + account: Account tagline = forms.StringField( __("Bio"), @@ -61,9 +60,9 @@ class ProfileForm(OrganizationForm): def set_queries(self) -> None: """Prepare form for use.""" - self.logo_url.profile = self.profile.name + self.logo_url.profile = self.account.name or self.account.buid - def make_for_user(self): + def make_for_user(self) -> None: """Customise form for a user account.""" self.title.label.text = __("Your name") self.title.description = __( @@ -71,10 +70,8 @@ def make_for_user(self): ) self.tagline.description = __("A brief statement about yourself") self.name.description = __( - "A short name for mentioning you with @username, and the URL to your" - " account’s page. Single word containing letters, numbers and dashes only." - " Pick something permanent: changing it will break existing links from" - " around the web" + "A single word that is uniquely yours, for your account page and @mentions." + " Pick something permanent: changing it will break existing links" ) self.description.label.text = __("More about you") self.description.description = __( @@ -82,11 +79,11 @@ def make_for_user(self): ) -@Profile.forms('transition') +@Account.forms('transition') class ProfileTransitionForm(forms.Form): """Form to transition an account between public and private state.""" - edit_obj: Profile + edit_obj: Account transition = forms.SelectField( __("Account visibility"), validators=[forms.validators.DataRequired()] @@ -97,16 +94,16 @@ def set_queries(self) -> None: self.transition.choices = list(self.edit_obj.state.transitions().items()) -@Profile.forms('logo') +@Account.forms('logo') class ProfileLogoForm(forms.Form): """ Form for profile logo. - A `profile` keyword argument is necessary for the ImgeeField. + An `account` keyword argument is necessary for the ImgeeField. """ - __expects__ = ('profile',) - profile: Profile + __expects__ = ('account',) + account: Account logo_url = forms.ImgeeField( __("Account image"), @@ -121,19 +118,19 @@ class ProfileLogoForm(forms.Form): def set_queries(self) -> None: """Prepare form for use.""" self.logo_url.widget_type = 'modal' - self.logo_url.profile = self.profile.name + self.logo_url.profile = self.account.name or self.account.buid -@Profile.forms('banner_image') +@Account.forms('banner_image') class ProfileBannerForm(forms.Form): """ Form for profile banner. - A `profile` keyword argument is necessary for the ImgeeField. + An `account` keyword argument is necessary for the ImgeeField. """ - __expects__ = ('profile',) - profile: Profile + __expects__ = ('account',) + account: Account banner_image_url = forms.ImgeeField( __("Banner image"), @@ -148,4 +145,4 @@ class ProfileBannerForm(forms.Form): def set_queries(self) -> None: """Prepare form for use.""" self.banner_image_url.widget_type = 'modal' - self.banner_image_url.profile = self.profile.name + self.banner_image_url.profile = self.account.name or self.account.buid diff --git a/funnel/forms/project.py b/funnel/forms/project.py index 298ee6414..546bd969a 100644 --- a/funnel/forms/project.py +++ b/funnel/forms/project.py @@ -2,15 +2,21 @@ from __future__ import annotations -from typing import Optional import re from baseframe import _, __, forms from baseframe.forms.sqlalchemy import AvailableName from coaster.utils import sorted_timezones, utcnow -from ..models import Profile, Project, Rsvp, SavedProject -from .helpers import ProfileSelectField, image_url_validator, nullable_strip_filters +from ..models import Account, Project, Rsvp, SavedProject +from .helpers import ( + AccountSelectField, + image_url_validator, + nullable_json_filters, + nullable_strip_filters, + validate_and_convert_json, + video_url_list_validator, +) __all__ = [ 'CfpForm', @@ -24,6 +30,7 @@ 'ProjectSponsorForm', 'RsvpTransitionForm', 'SavedProjectForm', + 'ProjectRegisterForm', ] double_quote_re = re.compile(r'["“”]') @@ -34,12 +41,12 @@ class ProjectForm(forms.Form): """ Form to create or edit a project. - A `profile` keyword argument is necessary for the ImgeeField. + An `account` keyword argument is necessary for the ImgeeField. """ - __expects__ = ('profile',) - profile: Profile - edit_obj: Optional[Project] + __expects__ = ('account',) + account: Account + edit_obj: Project | None title = forms.StringField( __("Title"), @@ -111,7 +118,7 @@ class ProjectForm(forms.Form): description=__("Landing page contents"), ) - def validate_location(self, field) -> None: + def validate_location(self, field: forms.Field) -> None: """Validate location field to not have quotes (from copy paste of hint).""" if re.search(double_quote_re, field.data) is not None: raise forms.validators.ValidationError( @@ -119,7 +126,7 @@ def validate_location(self, field) -> None: ) def set_queries(self) -> None: - self.bg_image.profile = self.profile.name + self.bg_image.profile = self.account.name or self.account.buid if self.edit_obj is not None and self.edit_obj.schedule_start_at: # Don't allow user to directly manipulate timestamps when it's done via # Session objects @@ -165,14 +172,19 @@ class ProjectLivestreamForm(forms.Form): ), ] ), + video_url_list_validator, ], ) + is_restricted_video = forms.BooleanField( + __("Restrict livestream to participants only") + ) + class ProjectNameForm(forms.Form): """Form to change the URL name of a project.""" - # TODO: Add validators for `profile` and unique name here instead of delegating to + # TODO: Add validators for `account` and unique name here instead of delegating to # the view. Also add `set_queries` method to change ``name.prefix`` name = forms.AnnotatedTextField( @@ -203,11 +215,11 @@ class ProjectBannerForm(forms.Form): """ Form for project banner. - A `profile` keyword argument is necessary for the ImgeeField. + An `account` keyword argument is necessary for the ImgeeField. """ - __expects__ = ('profile',) - profile: Profile + __expects__ = ('account',) + account: Account bg_image = forms.ImgeeField( __("Banner image"), @@ -222,7 +234,7 @@ class ProjectBannerForm(forms.Form): def set_queries(self) -> None: """Prepare form for use.""" self.bg_image.widget_type = 'modal' - self.bg_image.profile = self.profile.name + self.bg_image.profile = self.account.name or self.account.buid @Project.forms('cfp') @@ -245,7 +257,7 @@ class CfpForm(forms.Form): naive=False, ) - def validate_cfp_end_at(self, field) -> None: + def validate_cfp_end_at(self, field: forms.Field) -> None: """Validate closing date to be in the future.""" if field.data <= utcnow(): raise forms.validators.StopValidation( @@ -295,7 +307,7 @@ def set_open(self, obj: Project) -> None: class ProjectSponsorForm(forms.Form): """Form to add or edit a sponsor on a project.""" - profile = ProfileSelectField( + member = AccountSelectField( __("Account"), autocomplete_endpoint='/api/1/profile/autocomplete', results_key='profile', @@ -343,3 +355,33 @@ def set_queries(self) -> None: (transition_name, getattr(Rsvp, transition_name)) for transition_name in Rsvp.state.statemanager.transitions ] + + +@Project.forms('rsvp') +class ProjectRegisterForm(forms.Form): + """Register for a project with an optional custom JSON form.""" + + __expects__ = ('schema',) + schema: dict | None + + form = forms.TextAreaField( + __("Form"), + filters=nullable_json_filters, + validators=[validate_and_convert_json], + ) + + def validate_form(self, field: forms.Field) -> None: + if self.form.data and not self.schema: + raise forms.validators.StopValidation( + _("This registration is not expecting any form fields") + ) + if self.schema: + form_keys = set(self.form.data.keys()) + schema_keys = {i['name'] for i in self.schema['fields']} + if not form_keys.issubset(schema_keys): + invalid_keys = form_keys.difference(schema_keys) + raise forms.validators.StopValidation( + _("The form is not expecting these fields: {fields}").format( + fields=', '.join(invalid_keys) + ) + ) diff --git a/funnel/forms/proposal.py b/funnel/forms/proposal.py index b1ec67a28..a7e677ff5 100644 --- a/funnel/forms/proposal.py +++ b/funnel/forms/proposal.py @@ -2,12 +2,10 @@ from __future__ import annotations -from typing import Optional - from baseframe import _, __, forms from baseframe.forms.sqlalchemy import QuerySelectField -from ..models import Project, Proposal, User +from ..models import Account, Project, Proposal from .helpers import nullable_strip_filters, video_url_validator __all__ = [ @@ -29,8 +27,8 @@ def proposal_label_form( - project: Project, proposal: Optional[Proposal] -) -> Optional[forms.Form]: + project: Project, proposal: Proposal | None +) -> forms.Form | None: """Return a label form for the given project and proposal.""" if not project.labels: return None @@ -68,9 +66,10 @@ class ProposalLabelForm(forms.Form): def proposal_label_admin_form( - project: Project, proposal: Optional[Proposal] -) -> Optional[forms.Form]: + project: Project, proposal: Proposal | None +) -> forms.Form | None: """Return a label form to use in admin panel for given project and proposal.""" + # FIXME: See above class ProposalLabelAdminForm(forms.Form): """Forms for editor-selectable labels on a proposal.""" @@ -159,7 +158,9 @@ class ProposalForm(forms.Form): filters=[forms.filters.strip()], ) body = forms.MarkdownField( - __("Content"), validators=[forms.validators.DataRequired()] + __("Content"), + validators=[forms.validators.DataRequired()], + render_kw={'class': 'no-codemirror'}, ) video_url = forms.URLField( __("Video"), @@ -203,14 +204,14 @@ class ProposalMemberForm(forms.Form): description=__( "Optional – A specific role in this submission (like Author or Editor)" ), - filters=[forms.filters.strip()], + filters=nullable_strip_filters, ) is_uncredited = forms.BooleanField(__("Hide collaborator on submission")) - def validate_user(self, field) -> None: + def validate_user(self, field: forms.Field) -> None: """Validate user field to confirm user is not an existing collaborator.""" for membership in self.proposal.memberships: - if membership.user == field.data: + if membership.member == field.data: raise forms.validators.StopValidation( _("{user} is already a collaborator").format( user=field.data.pickername @@ -241,7 +242,7 @@ class ProposalMoveForm(forms.Form): """Form to move a proposal to another project.""" __expects__ = ('user',) - user: User + user: Account target = QuerySelectField( __("Move proposal to"), diff --git a/funnel/forms/session.py b/funnel/forms/session.py index 0b537dd11..2bec70d1e 100644 --- a/funnel/forms/session.py +++ b/funnel/forms/session.py @@ -54,7 +54,7 @@ class SessionForm(forms.Form): ) video_url = forms.URLField( __("Video URL"), - description=__("URL of the uploaded video after the session is over"), + description=__("URL of the session’s video (YouTube or Vimeo)"), validators=[ forms.validators.Optional(), forms.validators.URL(), @@ -63,6 +63,9 @@ class SessionForm(forms.Form): ], filters=nullable_strip_filters, ) + is_restricted_video = forms.BooleanField( + __("Restrict video to participants"), default=False + ) @SavedSession.forms('main') diff --git a/funnel/forms/sync_ticket.py b/funnel/forms/sync_ticket.py index f006dacde..1682820a5 100644 --- a/funnel/forms/sync_ticket.py +++ b/funnel/forms/sync_ticket.py @@ -2,21 +2,25 @@ from __future__ import annotations -from typing import Optional +import json + +from markupsafe import Markup from baseframe import __, forms from ..models import ( + Account, + AccountEmail, Project, TicketClient, TicketEvent, TicketParticipant, - User, - UserEmail, db, ) +from .helpers import nullable_json_filters, validate_and_convert_json __all__ = [ + 'FORM_SCHEMA_PLACEHOLDER', 'ProjectBoxofficeForm', 'TicketClientForm', 'TicketEventForm', @@ -25,8 +29,31 @@ 'TicketTypeForm', ] + BOXOFFICE_DETAILS_PLACEHOLDER = {'org': 'hasgeek', 'item_collection_id': ''} +FORM_SCHEMA_PLACEHOLDER = { + 'fields': [ + { + 'name': 'field_name', + 'title': "Field label shown to user", + 'description': "An explanation for this field", + 'type': "string", + }, + { + 'name': 'has_checked', + 'title': "I accept the terms", + 'type': 'boolean', + }, + { + 'name': 'choice', + 'title': "Choose one", + 'type': 'select', + 'choices': ["First choice", "Second choice", "Third choice"], + }, + ] +} + @Project.forms('boxoffice') class ProjectBoxofficeForm(forms.Form): @@ -42,11 +69,38 @@ class ProjectBoxofficeForm(forms.Form): filters=[forms.filters.strip()], ) allow_rsvp = forms.BooleanField( - __("Allow rsvp"), + __("Allow free registrations"), + default=False, + ) + is_subscription = forms.BooleanField( + __("Paid tickets are for a subscription"), + default=True, + ) + has_membership = forms.BooleanField( + __("Tickets on this project represent memberships to the account"), default=False, - description=__("If checked, both free and buy tickets will shown on project"), + ) + register_button_txt = forms.StringField( + __("Register button text"), + filters=[forms.filters.strip()], + description=__("Optional – Use with care to replace the button text"), + ) + register_form_schema = forms.StylesheetField( + __("Registration form"), + description=__("Optional – Specify fields as JSON (limited support)"), + filters=nullable_json_filters, + validators=[forms.validators.Optional(), validate_and_convert_json], ) + def set_queries(self): + """Set form schema description.""" + self.register_form_schema.description = Markup( + '

{description}

{schema}
' + ).format( + description=self.register_form_schema.description, + schema=json.dumps(FORM_SCHEMA_PLACEHOLDER, indent=2), + ) + @TicketEvent.forms('main') class TicketEventForm(forms.Form): @@ -59,7 +113,7 @@ class TicketEventForm(forms.Form): ) badge_template = forms.URLField( __("Badge template URL"), - description="URL of background image for the badge", + description=__("URL of background image for the badge"), validators=[forms.validators.Optional(), forms.validators.ValidUrl()], ) @@ -111,7 +165,7 @@ class TicketParticipantForm(forms.Form): """Form for a participant in a ticket.""" __returns__ = ('user',) - user: Optional[User] = None + user: Account | None = None edit_parent: Project fullname = forms.StringField( @@ -121,8 +175,8 @@ class TicketParticipantForm(forms.Form): ) email = forms.EmailField( __("Email"), - validators=[forms.validators.DataRequired(), forms.validators.ValidEmail()], - filters=[forms.filters.strip()], + validators=[forms.validators.Optional(), forms.validators.ValidEmail()], + filters=[forms.filters.none_if_empty()], ) phone = forms.StringField( __("Phone number"), @@ -165,10 +219,13 @@ def set_queries(self) -> None: def validate(self, *args, **kwargs) -> bool: """Validate form.""" result = super().validate(*args, **kwargs) + if self.email.data is None: + self.user = None + return True with db.session.no_autoflush: - useremail = UserEmail.get(email=self.email.data) - if useremail is not None: - self.user = useremail.user + accountemail = AccountEmail.get(email=self.email.data) + if accountemail is not None: + self.user = accountemail.account else: self.user = None return result diff --git a/funnel/forms/update.py b/funnel/forms/update.py index 3ef0cc4eb..15e121c27 100644 --- a/funnel/forms/update.py +++ b/funnel/forms/update.py @@ -27,5 +27,5 @@ class UpdateForm(forms.Form): __("Pin this update above other updates"), default=False ) is_restricted = forms.BooleanField( - __("Limit visibility to participants only"), default=False + __("Limit access to current participants only"), default=False ) diff --git a/funnel/forms/venue.py b/funnel/forms/venue.py index 37f7d94bf..b43cc44ff 100644 --- a/funnel/forms/venue.py +++ b/funnel/forms/venue.py @@ -5,9 +5,8 @@ import gettext import re -from flask_babel import get_locale - import pycountry +from flask_babel import get_locale from baseframe import _, __, forms from baseframe.forms.sqlalchemy import QuerySelectField @@ -102,7 +101,7 @@ class VenueRoomForm(forms.Form): default="CCCCCC", ) - def validate_bgcolor(self, field) -> None: + def validate_bgcolor(self, field: forms.Field) -> None: """Validate colour to be in RGB.""" if not valid_color_re.match(field.data): raise forms.validators.ValidationError( diff --git a/funnel/geoip.py b/funnel/geoip.py new file mode 100644 index 000000000..0c15a5854 --- /dev/null +++ b/funnel/geoip.py @@ -0,0 +1,53 @@ +"""GeoIP databases.""" + +import os.path +from dataclasses import dataclass + +from flask import Flask +from geoip2.database import Reader +from geoip2.errors import AddressNotFoundError, GeoIP2Error +from geoip2.models import ASN, City + +__all__ = ['GeoIP', 'geoip', 'GeoIP2Error', 'AddressNotFoundError'] + + +@dataclass +class GeoIP: + """Wrapper for GeoIP2 Reader.""" + + city_db: Reader | None = None + asn_db: Reader | None = None + + def __bool__(self) -> bool: + return self.city_db is not None or self.asn_db is not None + + def city(self, ipaddr: str) -> City | None: + if self.city_db: + return self.city_db.city(ipaddr) + return None + + def asn(self, ipaddr: str) -> ASN | None: + if self.asn_db: + return self.asn_db.asn(ipaddr) + return None + + def init_app(self, app: Flask) -> None: + if 'GEOIP_DB_CITY' in app.config: + if not os.path.exists(app.config['GEOIP_DB_CITY']): + app.logger.warning( + "GeoIP city database missing at %s", app.config['GEOIP_DB_CITY'] + ) + else: + self.city_db = Reader(app.config['GEOIP_DB_CITY']) + + if 'GEOIP_DB_ASN' in app.config: + if not os.path.exists(app.config['GEOIP_DB_ASN']): + app.logger.warning( + "GeoIP ASN database missing at %s", app.config['GEOIP_DB_ASN'] + ) + else: + self.asn_db = Reader(app.config['GEOIP_DB_ASN']) + + +# Export a singleton +geoip = GeoIP() diff --git a/funnel/loginproviders/github.py b/funnel/loginproviders/github.py index 7feac9be2..b16257523 100644 --- a/funnel/loginproviders/github.py +++ b/funnel/loginproviders/github.py @@ -2,11 +2,10 @@ from __future__ import annotations +import requests from flask import current_app, redirect, request - from furl import furl from sentry_sdk import capture_exception -import requests from baseframe import _ @@ -42,7 +41,7 @@ def callback(self) -> LoginProviderData: if request.args['error'] == 'redirect_uri_mismatch': # TODO: Log this as an exception for the server admin to look at raise LoginCallbackError( - _("This server's callback URL is misconfigured") + _("This server’s callback URL is misconfigured") ) raise LoginCallbackError(_("Unknown failure")) code = request.args.get('code', None) diff --git a/funnel/loginproviders/google.py b/funnel/loginproviders/google.py index c2af4c9b8..48d030c01 100644 --- a/funnel/loginproviders/google.py +++ b/funnel/loginproviders/google.py @@ -2,11 +2,10 @@ from __future__ import annotations +import requests from flask import current_app, redirect, request, session - from oauth2client import client from sentry_sdk import capture_exception -import requests from baseframe import _ diff --git a/funnel/loginproviders/linkedin.py b/funnel/loginproviders/linkedin.py index 97b499f98..9920ca266 100644 --- a/funnel/loginproviders/linkedin.py +++ b/funnel/loginproviders/linkedin.py @@ -4,11 +4,10 @@ from secrets import token_urlsafe +import requests from flask import current_app, redirect, request, session - from furl import furl from sentry_sdk import capture_exception -import requests from baseframe import _ @@ -18,8 +17,8 @@ class LinkedInProvider(LoginProvider): - auth_url = 'https://www.linkedin.com/uas/oauth2/authorization?response_type=code' - token_url = 'https://www.linkedin.com/uas/oauth2/accessToken' # nosec + auth_url = 'https://www.linkedin.com/oauth/v2/authorization?response_type=code' + token_url = 'https://www.linkedin.com/oauth/v2/accessToken' # nosec user_info = ( 'https://api.linkedin.com/v2/me?' 'projection=(id,localizedFirstName,localizedLastName)' @@ -58,7 +57,7 @@ def callback(self) -> LoginProviderData: if request.args['error'] == 'redirect_uri_mismatch': # TODO: Log this as an exception for the server admin to look at raise LoginCallbackError( - _("This server's callback URL is misconfigured") + _("This server’s callback URL is misconfigured") ) raise LoginCallbackError(_("Unknown failure")) code = request.args.get('code', None) diff --git a/funnel/loginproviders/twitter.py b/funnel/loginproviders/twitter.py index 071c2392c..243d99714 100644 --- a/funnel/loginproviders/twitter.py +++ b/funnel/loginproviders/twitter.py @@ -2,9 +2,8 @@ from __future__ import annotations -from flask import redirect, request - import tweepy +from flask import redirect, request from baseframe import _ @@ -27,7 +26,7 @@ def do(self, callback_url): try: redirect_url = auth.get_authorization_url() return redirect(redirect_url) - except tweepy.TweepError as exc: + except tweepy.errors.TweepyException as exc: raise LoginInitError( _("Twitter had a temporary problem. Try again?") ) from exc @@ -55,7 +54,7 @@ def callback(self) -> LoginProviderData: twuser = api.verify_credentials( include_entities='false', skip_status='true', include_email='true' ) - except tweepy.TweepError as exc: + except tweepy.errors.TweepyException as exc: raise LoginCallbackError( _("Twitter had an intermittent problem. Try again?") ) from exc diff --git a/funnel/loginproviders/zoom.py b/funnel/loginproviders/zoom.py index 290dc0548..9478548e9 100644 --- a/funnel/loginproviders/zoom.py +++ b/funnel/loginproviders/zoom.py @@ -4,11 +4,10 @@ from base64 import b64encode +import requests from flask import current_app, redirect, request, session - from furl import furl from sentry_sdk import capture_exception -import requests from baseframe import _ @@ -46,7 +45,7 @@ def callback(self) -> LoginProviderData: dict(request.args), ) raise LoginCallbackError( - _("This server's callback URL is misconfigured") + _("This server’s callback URL is misconfigured") ) raise LoginCallbackError(_("Unknown failure")) code = request.args.get('code', None) diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index 499806a46..eb243c69d 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -1,79 +1,78 @@ """Provide configuration for models and import all into a common `models` namespace.""" # flake8: noqa +# pylint: disable=unused-import from __future__ import annotations -from typing import TYPE_CHECKING, Callable, TypeVar - +import sqlalchemy as sa from flask_sqlalchemy import SQLAlchemy from sqlalchemy.dialects import postgresql -from sqlalchemy_json import mutable_json_type -from sqlalchemy_utils import LocaleType, TimezoneType, TSVectorType, UUIDType -import sqlalchemy as sa # noqa -import sqlalchemy.orm # Required to make sa.orm work # noqa +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, declared_attr +from sqlalchemy_utils import LocaleType, TimezoneType, TSVectorType from coaster.sqlalchemy import ( BaseIdNameMixin, BaseMixin, BaseNameMixin, + BaseScopedIdMixin, BaseScopedIdNameMixin, BaseScopedNameMixin, CoordinatesMixin, + DynamicMapped, + ModelBase, NoIdMixin, + Query, + QueryProperty, RegistryMixin, RoleMixin, TimestampMixin, UrlType, UuidMixin, + backref, + relationship, with_roles, ) -from ..typing import Mapped -if not TYPE_CHECKING: - # pylint: disable=ungrouped-imports - from sqlalchemy.ext.hybrid import hybrid_property - from sqlalchemy.orm import declarative_mixin, declared_attr -else: - from sqlalchemy.ext.declarative import declared_attr +class Model(ModelBase, DeclarativeBase): + """Base for all models.""" + + __with_timezone__ = True - hybrid_property = property - try: - # sqlalchemy-stubs (by Dropbox) can't find declarative_mixin, but - # sqlalchemy2-stubs (by SQLAlchemy) requires it - from sqlalchemy.orm import declarative_mixin # type: ignore[attr-defined] - except ImportError: - T = TypeVar('T') - def declarative_mixin(cls: T) -> T: - return cls +class GeonameModel(ModelBase, DeclarativeBase): + """Base for geoname models.""" + __bind_key__ = 'geoname' + __with_timezone__ = True -json_type: postgresql.JSONB = mutable_json_type(dbtype=postgresql.JSONB, nested=True) -db = SQLAlchemy() -# This must be set _before_ any of the models are imported +# This must be set _before_ any of the models using db.Model are imported TimestampMixin.__with_timezone__ = True +db = SQLAlchemy(query_class=Query, metadata=Model.metadata) # type: ignore[arg-type] +Model.init_flask_sqlalchemy(db) +GeonameModel.init_flask_sqlalchemy(db) + # Some of these imports are order sensitive due to circular dependencies # All of them have to be imported after TimestampMixin is patched # pylint: disable=wrong-import-position +from . import types # isort:skip from .helpers import * # isort:skip -from .user import * # isort:skip +from .account import * # isort:skip from .user_signals import * # isort:skip -from .user_session import * # isort:skip +from .login_session import * # isort:skip from .email_address import * # isort:skip from .phone_number import * # isort:skip from .auth_client import * # isort:skip -from .notification import * # isort:skip from .utils import * # isort:skip from .comment import * # isort:skip from .draft import * # isort:skip from .sync_ticket import * # isort:skip from .contact_exchange import * # isort:skip from .label import * # isort:skip -from .profile import * # isort:skip from .project import * # isort:skip from .update import * # isort:skip from .proposal import * # isort:skip @@ -83,13 +82,16 @@ def declarative_mixin(cls: T) -> T: from .shortlink import * # isort:skip from .venue import * # isort:skip from .video_mixin import * # isort:skip +from .mailer import * # isort:skip from .membership_mixin import * # isort:skip -from .organization_membership import * # isort:skip +from .account_membership import * # isort:skip from .project_membership import * # isort:skip from .sponsor_membership import * # isort:skip from .proposal_membership import * # isort:skip from .site_membership import * # isort:skip from .moderation import * # isort:skip -from .notification_types import * # isort:skip from .commentset_membership import * # isort:skip from .geoname import * # isort:skip +from .typing import * # isort:skip +from .notification import * # isort:skip +from .notification_types import * # isort:skip diff --git a/funnel/models/account.py b/funnel/models/account.py new file mode 100644 index 000000000..b594dd2d5 --- /dev/null +++ b/funnel/models/account.py @@ -0,0 +1,2195 @@ +"""Account model with subtypes, and account-linked personal data models.""" + +from __future__ import annotations + +import hashlib +import itertools +from collections.abc import Iterable, Iterator +from datetime import datetime, timedelta +from typing import ClassVar, Literal, Union, cast, overload +from uuid import UUID + +import phonenumbers +from babel import Locale +from furl import furl +from passlib.hash import argon2, bcrypt +from pytz.tzinfo import BaseTzInfo +from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy +from sqlalchemy.ext.hybrid import Comparator +from sqlalchemy.sql.expression import ColumnElement +from werkzeug.utils import cached_property +from zbase32 import decode as zbase32_decode, encode as zbase32_encode + +from baseframe import __ +from coaster.sqlalchemy import ( + LazyRoleSet, + RoleMixin, + StateManager, + add_primary_relationship, + auto_init_default, + failsafe_add, + immutable, + with_roles, +) +from coaster.utils import LabeledEnum, newsecret, require_one_of, utcnow + +from ..typing import OptionalMigratedTables +from . import ( + BaseMixin, + DynamicMapped, + LocaleType, + Mapped, + Model, + Query, + TimezoneType, + TSVectorType, + UrlType, + UuidMixin, + backref, + db, + hybrid_property, + relationship, + sa, +) +from .email_address import EmailAddress, EmailAddressMixin +from .helpers import ( + RESERVED_NAMES, + ImgeeType, + MarkdownCompositeDocument, + add_search_trigger, + quote_autocomplete_like, + quote_autocomplete_tsquery, + valid_account_name, + visual_field_delimiter, +) +from .phone_number import PhoneNumber, PhoneNumberMixin + +__all__ = [ + 'ACCOUNT_STATE', + 'deleted_account', + 'removed_account', + 'unknown_account', + 'User', + 'DuckTypeAccount', + 'AccountOldId', + 'Organization', + 'Team', + 'Placeholder', + 'AccountEmail', + 'AccountEmailClaim', + 'AccountPhone', + 'AccountExternalId', + 'Anchor', +] + + +class ACCOUNT_STATE(LabeledEnum): # noqa: N801 + """State codes for accounts.""" + + #: Regular, active account + ACTIVE = (1, __("Active")) + #: Suspended account (cause and explanation not included here) + SUSPENDED = (2, __("Suspended")) + #: Merged into another account + MERGED = (3, __("Merged")) + #: Permanently deleted account + DELETED = (5, __("Deleted")) + + #: This account is gone + GONE = {MERGED, DELETED} + + +class PROFILE_STATE(LabeledEnum): # noqa: N801 + """The visibility state of an account (auto/public/private).""" + + AUTO = (1, 'auto', __("Autogenerated")) + PUBLIC = (2, 'public', __("Public")) + PRIVATE = (3, 'private', __("Private")) + + NOT_PUBLIC = {AUTO, PRIVATE} + NOT_PRIVATE = {AUTO, PUBLIC} + + +class ZBase32Comparator(Comparator[str]): # pylint: disable=abstract-method + """Comparator to allow lookup by Account.uuid_zbase32.""" + + def __eq__(self, other: object) -> sa.ColumnElement[bool]: # type: ignore[override] + """Return an expression for column == other.""" + try: + return self.__clause_element__() == UUID( # type: ignore[return-value] + bytes=zbase32_decode(str(other)) + ) + except ValueError: # zbase32 call failed, so it's not a valid string + return sa.false() + + +class Account(UuidMixin, BaseMixin, Model): + """Account model.""" + + __tablename__ = 'account' + # Name has a length limit 63 to fit DNS label limit + __name_length__ = 63 + # Titles can be longer + __title_length__ = 80 + + __active_membership_attrs__: ClassVar[set[str]] = set() + __noninvite_membership_attrs__: ClassVar[set[str]] = set() + + # Helper flags (see subclasses) + is_user_profile: ClassVar[bool] = False + is_organization_profile: ClassVar[bool] = False + is_placeholder_profile: ClassVar[bool] = False + + reserved_names: ClassVar[set[str]] = RESERVED_NAMES + + type_: Mapped[str] = sa.orm.mapped_column('type', sa.CHAR(1), nullable=False) + + #: Join date for users and organizations (skipped for placeholders) + joined_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + + #: The optional "username", used in the URL stub, with a unique constraint on the + #: lowercase value (defined in __table_args__ below) + name: Mapped[str | None] = with_roles( + sa.orm.mapped_column( + sa.Unicode(__name_length__), + sa.CheckConstraint("name <> ''"), + nullable=True, + ), + read={'all'}, + ) + + #: The account's title (user's fullname) + title: Mapped[str] = with_roles( + sa.orm.mapped_column(sa.Unicode(__title_length__), default='', nullable=False), + read={'all'}, + ) + #: Alias title as user's fullname + fullname: Mapped[str] = sa.orm.synonym('title') + #: Alias name as user's username + username: Mapped[str] = sa.orm.synonym('name') + + #: Argon2 or Bcrypt hash of the user's password + pw_hash: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode, nullable=True) + #: Timestamp for when the user's password last changed + pw_set_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + #: Expiry date for the password (to prompt user to reset it) + pw_expires_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + #: User's preferred/last known timezone + timezone: Mapped[BaseTzInfo | None] = with_roles( + sa.orm.mapped_column(TimezoneType(backend='pytz'), nullable=True), + read={'owner'}, + ) + #: Update timezone automatically from browser activity + auto_timezone: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, default=True, nullable=False + ) + #: User's preferred/last known locale + locale: Mapped[Locale | None] = with_roles( + sa.orm.mapped_column(LocaleType, nullable=True), read={'owner'} + ) + #: Update locale automatically from browser activity + auto_locale: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, default=True, nullable=False + ) + #: User's state code (active, suspended, merged, deleted) + _state: Mapped[int] = sa.orm.mapped_column( + 'state', + sa.SmallInteger, + StateManager.check_constraint('state', ACCOUNT_STATE), + nullable=False, + default=ACCOUNT_STATE.ACTIVE, + ) + #: Account state manager + state = StateManager('_state', ACCOUNT_STATE, doc="Account state") + #: Other accounts that were merged into this account + old_accounts: AssociationProxy[list[Account]] = association_proxy( + 'oldids', 'old_account' + ) + + _profile_state: Mapped[int] = sa.orm.mapped_column( + 'profile_state', + sa.SmallInteger, + StateManager.check_constraint('profile_state', PROFILE_STATE), + nullable=False, + default=PROFILE_STATE.AUTO, + ) + profile_state = StateManager( + '_profile_state', PROFILE_STATE, doc="Current state of the account profile" + ) + + tagline: Mapped[str | None] = sa.orm.mapped_column( + sa.Unicode, sa.CheckConstraint("tagline <> ''"), nullable=True + ) + description, description_text, description_html = MarkdownCompositeDocument.create( + 'description', default='', nullable=False + ) + website: Mapped[furl | None] = sa.orm.mapped_column( + UrlType, sa.CheckConstraint("website <> ''"), nullable=True + ) + logo_url: Mapped[furl | None] = sa.orm.mapped_column( + ImgeeType, sa.CheckConstraint("logo_url <> ''"), nullable=True + ) + banner_image_url: Mapped[furl | None] = sa.orm.mapped_column( + ImgeeType, sa.CheckConstraint("banner_image_url <> ''"), nullable=True + ) + + # These two flags are read-only. There is no provision for writing to them within + # the app: + + #: Protected accounts cannot be deleted + is_protected: Mapped[bool] = with_roles( + immutable(sa.orm.mapped_column(sa.Boolean, default=False, nullable=False)), + read={'owner', 'admin'}, + ) + #: Verified accounts get listed on the home page and are not considered throwaway + #: accounts for spam control. There are no other privileges at this time + is_verified: Mapped[bool] = with_roles( + immutable( + sa.orm.mapped_column(sa.Boolean, default=False, nullable=False, index=True) + ), + read={'all'}, + ) + + #: Revision number maintained by SQLAlchemy, starting at 1 + revisionid: Mapped[int] = with_roles( + sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + ) + + search_vector: Mapped[str] = sa.orm.mapped_column( + TSVectorType( + 'title', + 'name', + 'tagline', + 'description_text', + weights={ + 'title': 'A', + 'name': 'A', + 'tagline': 'B', + 'description_text': 'B', + }, + regconfig='english', + hltext=lambda: sa.func.concat_ws( + visual_field_delimiter, + Account.title, + Account.name, + Account.tagline, + Account.description_html, + ), + ), + nullable=False, + deferred=True, + ) + + name_vector: Mapped[str] = sa.orm.mapped_column( + TSVectorType( + 'title', + 'name', + regconfig='simple', + hltext=lambda: sa.func.concat_ws(' @', Account.title, Account.name), + ), + nullable=False, + deferred=True, + ) + + __table_args__ = ( + sa.Index( + 'ix_account_name_lower', + sa.func.lower(name).label('name_lower'), + unique=True, + postgresql_ops={'name_lower': 'varchar_pattern_ops'}, + ), + sa.Index( + 'ix_account_title_lower', + sa.func.lower(title).label('title_lower'), + postgresql_ops={'title_lower': 'varchar_pattern_ops'}, + ), + sa.Index('ix_account_search_vector', 'search_vector', postgresql_using='gin'), + sa.Index('ix_account_name_vector', 'name_vector', postgresql_using='gin'), + ) + + __mapper_args__ = { + # 'polymorphic_identity' from subclasses is stored in the type column + 'polymorphic_on': type_, + # When querying the Account model, cast automatically to all subclasses + 'with_polymorphic': '*', + 'version_id_col': revisionid, + } + + __roles__ = { + 'all': { + 'read': { + 'uuid', + 'name', + 'urlname', + 'title', + 'fullname', + 'username', + 'pickername', + 'timezone', + 'description', + 'website', + 'logo_url', + 'banner_image_url', + 'joined_at', + 'profile_url', + 'urls', + 'is_user_profile', + 'is_organization_profile', + 'is_placeholder_profile', + }, + 'call': {'views', 'forms', 'features', 'url_for', 'state', 'profile_state'}, + } + } + + __datasets__ = { + 'primary': { + 'urls', + 'uuid_b58', + 'name', + 'urlname', + 'title', + 'fullname', + 'username', + 'pickername', + 'timezone', + 'description', + 'logo_url', + 'website', + 'joined_at', + 'profile_url', + 'is_verified', + }, + 'related': { + 'urls', + 'uuid_b58', + 'name', + 'urlname', + 'title', + 'fullname', + 'username', + 'pickername', + 'timezone', + 'description', + 'logo_url', + 'joined_at', + 'profile_url', + 'is_verified', + }, + } + + profile_state.add_conditional_state( + 'ACTIVE_AND_PUBLIC', + profile_state.PUBLIC, + lambda account: bool(account.state.ACTIVE), + ) + + @classmethod + def _defercols(cls) -> list[sa.orm.interfaces.LoaderOption]: + """Return columns that are typically deferred when loading a user.""" + defer = sa.orm.defer + return [ + defer(cls.created_at), + defer(cls.updated_at), + defer(cls.pw_hash), + defer(cls.pw_set_at), + defer(cls.pw_expires_at), + defer(cls.timezone), + ] + + @classmethod + def type_filter(cls) -> sa.ColumnElement[bool]: + """Return filter for the subclass's type.""" + return cls.type_ == cls.__mapper_args__.get('polymorphic_identity') + + primary_email: Mapped[AccountEmail | None] = relationship() + primary_phone: Mapped[AccountPhone | None] = relationship() + + def __repr__(self) -> str: + if self.name: + return f'<{self.__class__.__name__} {self.title} @{self.name}>' + return f'<{self.__class__.__name__} {self.title}>' + + def __str__(self) -> str: + """Return picker name for account.""" + return self.pickername + + def __format__(self, format_spec: str) -> str: + if not format_spec: + return self.pickername + return self.pickername.__format__(format_spec) + + @property + def pickername(self) -> str: + """Return title and @name in a format suitable for identification.""" + if self.name: + return f'{self.title} (@{self.name})' + return self.title + + with_roles(pickername, read={'all'}) + + def roles_for( + self, actor: Account | None = None, anchors: Iterable = () + ) -> LazyRoleSet: + """Identify roles for the given actor.""" + roles = super().roles_for(actor, anchors) + if self.profile_state.ACTIVE_AND_PUBLIC: + roles.add('reader') + return roles + + @cached_property + def verified_contact_count(self) -> int: + """Count of verified contact details.""" + return len(self.emails) + len(self.phones) + + @property + def has_verified_contact_info(self) -> bool: + """User has any verified contact info (email or phone).""" + return bool(self.emails) or bool(self.phones) + + @property + def has_contact_info(self) -> bool: + """User has any contact information (including unverified).""" + return self.has_verified_contact_info or bool(self.emailclaims) + + def merged_account(self) -> Account: + """Return the account that this account was merged into (default: self).""" + if self.state.MERGED: + # If our state is MERGED, there _must_ be a corresponding AccountOldId + # record + return cast(AccountOldId, AccountOldId.get(self.uuid)).account + return self + + def _set_password(self, password: str | None): + """Set a password (write-only property).""" + if password is None: + self.pw_hash = None + else: + self.pw_hash = argon2.hash(password) + # Also see :meth:`password_is` for transparent upgrade + self.pw_set_at = sa.func.utcnow() + # Expire passwords after one year. TODO: make this configurable + self.pw_expires_at = self.pw_set_at + timedelta(days=365) + + #: Write-only property (passwords cannot be read back in plain text) + password = property(fset=_set_password, doc=_set_password.__doc__) + + def password_has_expired(self) -> bool: + """Verify if password expiry timestamp has passed.""" + return ( + self.pw_hash is not None + and self.pw_expires_at is not None + and self.pw_expires_at <= utcnow() + ) + + def password_is(self, password: str, upgrade_hash: bool = False) -> bool: + """Test if the candidate password matches saved hash.""" + if self.pw_hash is None: + return False + + # Passwords may use the current Argon2 scheme or the older Bcrypt scheme. + # Bcrypt passwords are transparently upgraded if requested. + if argon2.identify(self.pw_hash): + return argon2.verify(password, self.pw_hash) + if bcrypt.identify(self.pw_hash): + verified = bcrypt.verify(password, self.pw_hash) + if verified and upgrade_hash: + self.pw_hash = argon2.hash(password) + return verified + return False + + def add_email( + self, + email: str, + primary: bool = False, + private: bool = False, + ) -> AccountEmail: + """Add an email address (assumed to be verified).""" + accountemail = AccountEmail(account=self, email=email, private=private) + accountemail = cast( + AccountEmail, + failsafe_add( + db.session, + accountemail, + account=self, + email_address=accountemail.email_address, + ), + ) + if primary: + self.primary_email = accountemail + return accountemail + # FIXME: This should remove competing instances of AccountEmailClaim + + def del_email(self, email: str) -> None: + """Remove an email address from the user's account.""" + accountemail = AccountEmail.get_for(account=self, email=email) + if accountemail is not None: + if self.primary_email in (accountemail, None): + self.primary_email = ( + AccountEmail.query.filter( + AccountEmail.account == self, AccountEmail.id != accountemail.id + ) + .order_by(AccountEmail.created_at.desc()) + .first() + ) + db.session.delete(accountemail) + + @property + def email(self) -> Literal[''] | AccountEmail: + """Return primary email address for user.""" + # Look for a primary address + accountemail = self.primary_email + if accountemail is not None: + return accountemail + # No primary? Maybe there's one that's not set as primary? + if self.emails: + accountemail = self.emails[0] + # XXX: Mark as primary. This may or may not be saved depending on + # whether the request ended in a database commit. + self.primary_email = accountemail + return accountemail + # This user has no email address. Return a blank string instead of None + # to support the common use case, where the caller will use str(user.email) + # to get the email address as a string. + return '' + + with_roles(email, read={'owner'}) + + def add_phone( + self, + phone: str, + primary: bool = False, + private: bool = False, + ) -> AccountPhone: + """Add a phone number (assumed to be verified).""" + accountphone = AccountPhone(account=self, phone=phone, private=private) + accountphone = cast( + AccountPhone, + failsafe_add( + db.session, + accountphone, + account=self, + phone_number=accountphone.phone_number, + ), + ) + if primary: + self.primary_phone = accountphone + return accountphone + + def del_phone(self, phone: str) -> None: + """Remove a phone number from the user's account.""" + accountphone = AccountPhone.get_for(account=self, phone=phone) + if accountphone is not None: + if self.primary_phone in (accountphone, None): + self.primary_phone = ( + AccountPhone.query.filter( + AccountPhone.account == self, AccountPhone.id != accountphone.id + ) + .order_by(AccountPhone.created_at.desc()) + .first() + ) + db.session.delete(accountphone) + + @property + def phone(self) -> Literal[''] | AccountPhone: + """Return primary phone number for user.""" + # Look for a primary phone number + accountphone = self.primary_phone + if accountphone is not None: + return accountphone + # No primary? Maybe there's one that's not set as primary? + if self.phones: + accountphone = self.phones[0] + # XXX: Mark as primary. This may or may not be saved depending on + # whether the request ended in a database commit. + self.primary_phone = accountphone + return accountphone + # This user has no phone number. Return a blank string instead of None + # to support the common use case, where the caller will use str(user.phone) + # to get the phone number as a string. + return '' + + with_roles(phone, read={'owner'}) + + @property + def has_public_profile(self) -> bool: + """Return the visibility state of an account.""" + return self.name is not None and bool(self.profile_state.ACTIVE_AND_PUBLIC) + + with_roles(has_public_profile, read={'all'}, write={'owner'}) + + @property + def profile_url(self) -> str | None: + """Return optional URL to account profile page.""" + return self.url_for(_external=True) + + with_roles(profile_url, read={'all'}) + + def is_profile_complete(self) -> bool: + """Verify if profile is complete (fullname, username and contacts present).""" + return bool(self.title and self.name and self.has_verified_contact_info) + + def active_memberships(self) -> Iterator[ImmutableMembershipMixin]: + """Enumerate all active memberships.""" + # Each collection is cast into a list before chaining to ensure that it does not + # change during processing (if, for example, membership is revoked or replaced). + return itertools.chain( + *(list(getattr(self, attr)) for attr in self.__active_membership_attrs__) + ) + + def has_any_memberships(self) -> bool: + """ + Test for any non-invite membership records that must be preserved. + + This is used to test for whether the account is safe to purge (hard delete) from + the database. If non-invite memberships are present, the account cannot be + purged as immutable records must be preserved. Instead, the account must be put + into DELETED state with all PII scrubbed. + """ + return any( + db.session.query(getattr(self, attr).exists()).scalar() + for attr in self.__noninvite_membership_attrs__ + ) + + # --- Transport details + + @with_roles(call={'owner'}) + def has_transport_email(self) -> bool: + """User has an email transport address.""" + return self.state.ACTIVE and bool(self.email) + + @with_roles(call={'owner'}) + def has_transport_sms(self) -> bool: + """User has an SMS transport address.""" + return ( + self.state.ACTIVE + and self.phone != '' + and self.phone.phone_number.has_sms is not False + ) + + @with_roles(call={'owner'}) + def has_transport_webpush(self) -> bool: # TODO # pragma: no cover + """User has a webpush transport address.""" + return False + + @with_roles(call={'owner'}) + def has_transport_telegram(self) -> bool: # TODO # pragma: no cover + """User has a Telegram transport address.""" + return False + + @with_roles(call={'owner'}) + def has_transport_whatsapp(self) -> bool: + """User has a WhatsApp transport address.""" + return ( + self.state.ACTIVE + and self.phone != '' + and self.phone.phone_number.has_wa is not False + ) + + @with_roles(call={'owner'}) + def transport_for_email(self, context: Model | None = None) -> AccountEmail | None: + """Return user's preferred email address within a context.""" + # TODO: Per-account/project customization is a future option + if self.state.ACTIVE: + return self.email or None + return None + + @with_roles(call={'owner'}) + def transport_for_sms(self, context: Model | None = None) -> AccountPhone | None: + """Return user's preferred phone number within a context.""" + # TODO: Per-account/project customization is a future option + if ( + self.state.ACTIVE + and self.phone != '' + and self.phone.phone_number.has_sms is not False + ): + return self.phone + return None + + @with_roles(call={'owner'}) + def transport_for_webpush( + self, context: Model | None = None + ): # TODO # pragma: no cover + """Return user's preferred webpush transport address within a context.""" + return None + + @with_roles(call={'owner'}) + def transport_for_telegram( + self, context: Model | None = None + ): # TODO # pragma: no cover + """Return user's preferred Telegram transport address within a context.""" + return None + + @with_roles(call={'owner'}) + def transport_for_whatsapp(self, context: Model | None = None): + """Return user's preferred WhatsApp transport address within a context.""" + # TODO: Per-account/project customization is a future option + if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.allow_wa: + return self.phone + return None + + @with_roles(call={'owner'}) + def transport_for_signal(self, context: Model | None = None): + """Return user's preferred Signal transport address within a context.""" + # TODO: Per-account/project customization is a future option + if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.allow_sm: + return self.phone + return None + + @with_roles(call={'owner'}) + def has_transport(self, transport: str) -> bool: + """ + Verify if user has a given transport address. + + Helper method to call ``self.has_transport_()``. + + ..note:: + Because this method does not accept a context, it may return True for a + transport that has been muted in that context. This may cause an empty + background job to be queued for a notification. Revisit this method when + preference contexts are supported. + """ + return getattr(self, 'has_transport_' + transport)() + + @with_roles(call={'owner'}) + def transport_for( + self, transport: str, context: Model | None = None + ) -> AccountEmail | AccountPhone | None: + """ + Get transport address for a given transport and context. + + Helper method to call ``self.transport_for_(context)``. + """ + return getattr(self, 'transport_for_' + transport)(context) + + def default_email( + self, context: Model | None = None + ) -> AccountEmail | AccountEmailClaim | None: + """ + Return default email address (verified if present, else unverified). + + ..note:: + This is a temporary helper method, pending merger of + :class:`AccountEmailClaim` into :class:`AccountEmail` with + :attr:`~AccountEmail.verified` ``== False``. The appropriate replacement is + :meth:`Account.transport_for_email` with a context. + """ + email = self.transport_for_email(context=context) + if email: + return email + # Fallback when ``transport_for_email`` returns None + if self.email: + return self.email + if self.emailclaims: + return self.emailclaims[0] + # This user has no email addresses + return None + + @property + def _self_is_owner_and_admin_of_self(self) -> Account: + """ + Return self. + + Helper method for :meth:`roles_for` and :meth:`actors_with` to assert that the + user is owner and admin of their own account. + """ + return self + + with_roles(_self_is_owner_and_admin_of_self, grants={'owner', 'admin'}) + + def organizations_as_owner_ids(self) -> list[int]: + """ + Return the database ids of the organizations this user is an owner of. + + This is used for database queries. + """ + return [ + membership.account_id + for membership in self.active_organization_owner_memberships + ] + + @state.transition(state.ACTIVE, state.MERGED) + def mark_merged_into(self, other_account): + """Mark account as merged into another account.""" + db.session.add(AccountOldId(id=self.uuid, account=other_account)) + + @state.transition(state.ACTIVE, state.SUSPENDED) + def mark_suspended(self): + """Mark account as suspended on support or moderator request.""" + + @state.transition(state.SUSPENDED, state.ACTIVE) + def mark_active(self): + """Restore a suspended account to active state.""" + + @state.transition(state.ACTIVE, state.DELETED) + def do_delete(self): + """Delete account.""" + # 0: Safety check + if not self.is_safe_to_delete(): + raise ValueError("Account cannot be deleted") + + # 1. Delete contact information + for contact_source in ( + self.emails, + self.emailclaims, + self.phones, + self.externalids, + ): + for contact in contact_source: + db.session.delete(contact) + + # 2. Revoke all active memberships + for membership in self.active_memberships(): + membership = membership.freeze_member_attribution(self) + if membership.revoke_on_member_delete: + membership.revoke(actor=self) + # TODO: freeze fullname in unrevoked memberships (pending title column there) + if ( + self.active_site_membership + and self.active_site_membership.revoke_on_member_delete + ): + self.active_site_membership.revoke(actor=self) + + # 3. Drop all team memberships + self.member_teams.clear() + + # 4. Revoke auth tokens + self.revoke_all_auth_tokens() # Defined in auth_client.py + self.revoke_all_auth_client_permissions() # Same place + + # 5. Revoke all active login sessions + for login_session in self.active_login_sessions: + login_session.revoke() + + # 6. Clear name (username), title (fullname) and stored password hash + self.name = None + self.title = '' + self.password = None + + # 7. Unassign tickets assigned to the user + self.ticket_participants = [] # pylint: disable=attribute-defined-outside-init + + @with_roles(call={'owner'}) + @profile_state.transition( + profile_state.NOT_PUBLIC, + profile_state.PUBLIC, + title=__("Make public"), + ) + @state.requires(state.ACTIVE) + def make_profile_public(self) -> None: + """Make an account public if it is eligible.""" + + @with_roles(call={'owner'}) + @profile_state.transition( + profile_state.NOT_PRIVATE, profile_state.PRIVATE, title=__("Make private") + ) + def make_profile_private(self) -> None: + """Make an account private.""" + + def is_safe_to_delete(self) -> bool: + """Test if account is not protected and has no projects.""" + return self.is_protected is False and self.projects.count() == 0 + + def is_safe_to_purge(self) -> bool: + """Test if account is safe to delete and has no memberships (active or not).""" + return self.is_safe_to_delete() and not self.has_any_memberships() + + @property + def urlname(self) -> str: + """Return :attr:`name` or ``~``-prefixed :attr:`uuid_zbase32`.""" + if self.name is not None: + return self.name + return f'~{self.uuid_zbase32}' + + @hybrid_property + def uuid_zbase32(self) -> str: + """Account UUID rendered in z-Base-32.""" + return zbase32_encode(self.uuid.bytes) + + @uuid_zbase32.inplace.comparator + @classmethod + def _uuid_zbase32_comparator(cls) -> ZBase32Comparator: + """Return SQL comparator for :prop:`uuid_zbase32`.""" + return ZBase32Comparator(cls.uuid) + + @classmethod + def name_is(cls, name: str) -> ColumnElement: + """Generate query filter to check if name is matching (case insensitive).""" + if name.startswith('~'): + return cls.uuid_zbase32 == name[1:] + return sa.func.lower(cls.name) == sa.func.lower(sa.func.replace(name, '-', '_')) + + @classmethod + def name_in(cls, names: Iterable[str]) -> ColumnElement: + """Generate query flter to check if name is among candidates.""" + return sa.func.lower(cls.name).in_( + [name.lower().replace('-', '_') for name in names] + ) + + @classmethod + def name_like(cls, like_query: str) -> ColumnElement: + """Generate query filter for a LIKE query on name.""" + return sa.func.lower(cls.name).like( + sa.func.lower(sa.func.replace(like_query, '-', r'\_')) + ) + + @overload + @classmethod + def get( + cls, + *, + name: str, + defercols: bool = False, + ) -> Account | None: + ... + + @overload + @classmethod + def get( + cls, + *, + buid: str, + defercols: bool = False, + ) -> Account | None: + ... + + @overload + @classmethod + def get( + cls, + *, + userid: str, + defercols: bool = False, + ) -> Account | None: + ... + + @classmethod + def get( + cls, + *, + name: str | None = None, + buid: str | None = None, + userid: str | None = None, + defercols: bool = False, + ) -> Account | None: + """ + Return an Account with the given name or buid. + + :param str name: Username to lookup + :param str buid: Buid to lookup + :param bool defercols: Defer loading non-critical columns + """ + require_one_of(name=name, buid=buid, userid=userid) + + # userid parameter is temporary for Flask-Lastuser compatibility + if userid: + buid = userid + + if name is not None: + query = cls.query.filter(cls.name_is(name)) + else: + query = cls.query.filter_by(buid=buid) + if cls is not Account: + query = query.filter(cls.type_filter()) + if defercols: + query = query.options(*cls._defercols()) + account = query.one_or_none() + if account and account.state.MERGED: + account = account.merged_account() + if account and account.state.ACTIVE: + return account + return None + + @classmethod + def all( # noqa: A003 + cls, + buids: Iterable[str] | None = None, + names: Iterable[str] | None = None, + defercols: bool = False, + ) -> list[Account]: + """ + Return all matching accounts. + + :param list buids: Buids to look up + :param list names: Names (usernames) to look up + :param bool defercols: Defer loading non-critical columns + """ + accounts = set() + if buids and names: + query = cls.query.filter(sa.or_(cls.buid.in_(buids), cls.name_in(names))) + elif buids: + query = cls.query.filter(cls.buid.in_(buids)) + elif names: + query = cls.query.filter(cls.name_in(names)) + else: + return [] + if cls is not Account: + query = query.filter(cls.type_filter()) + + if defercols: + query = query.options(*cls._defercols()) + for account in query.all(): + account = account.merged_account() + if account.state.ACTIVE: + accounts.add(account) + return list(accounts) + + @classmethod + def all_public(cls) -> Query: + """Construct a query filtered by public profile state.""" + query = cls.query.filter(cls.profile_state.PUBLIC) + if cls is not Account: + query = query.filter(cls.type_filter()) + return query + + @classmethod + def autocomplete(cls, prefix: str) -> list[Account]: + """ + Return accounts whose names begin with the prefix, for autocomplete UI. + + Looks up accounts by title, name, external ids and email addresses. + + :param prefix: Letters to start matching with + """ + like_query = quote_autocomplete_like(prefix) + if not like_query or like_query == '@%': + return [] + tsquery = quote_autocomplete_tsquery(prefix) + + # base_users is used in two of the three possible queries below + base_users = cls.query.filter( + cls.state.ACTIVE, + cls.name_vector.bool_op('@@')(tsquery), + ) + + if cls is not Account: + base_users = base_users.filter(cls.type_filter()) + base_users = ( + base_users.options(*cls._defercols()).order_by(Account.title).limit(20) + ) + + if ( + prefix != '@' + and prefix.startswith('@') + and AccountExternalId.__at_username_services__ + ): + # @-prefixed, so look for usernames, including other @username-using + # services like Twitter and GitHub. Make a union of three queries. + users = ( + # Query 1: @query -> Account.name + cls.query.filter( + cls.state.ACTIVE, + cls.name_like(like_query[1:]), + ) + .options(*cls._defercols()) + .limit(20) + # FIXME: Still broken as of SQLAlchemy 1.4.23 (also see next block) + # .union( + # # Query 2: @query -> UserExternalId.username + # cls.query.join(UserExternalId) + # .filter( + # cls.state.ACTIVE, + # UserExternalId.service.in_( + # UserExternalId.__at_username_services__ + # ), + # sa.func.lower(UserExternalId.username).like( + # sa.func.lower(like_query[1:]) + # ), + # ) + # .options(*cls._defercols()) + # .limit(20), + # # Query 3: like_query -> Account.title + # cls.query.filter( + # cls.state.ACTIVE, + # sa.func.lower(cls.title).like(sa.func.lower(like_query)), + # ) + # .options(*cls._defercols()) + # .limit(20), + # ) + .all() + ) + elif '@' in prefix and not prefix.startswith('@'): + # Query has an @ in the middle. Match email address (exact match only). + # Use param `prefix` instead of `like_query` because it's not a LIKE query. + # Combine results with regular user search + email_filter = EmailAddress.get_filter(email=prefix) + if email_filter is not None: + users = ( + cls.query.join(AccountEmail) + .join(EmailAddress) + .filter(email_filter, cls.state.ACTIVE) + .options(*cls._defercols()) + .limit(20) + # .union(base_users) # FIXME: Broken in SQLAlchemy 1.4.17 + .all() + ) + else: + users = [] + else: + # No '@' in the query, so do a regular autocomplete + try: + users = base_users.all() + except sa.exc.ProgrammingError: + # This can happen because the tsquery from prefix turned out to be ':*' + users = [] + return users + + @classmethod + def validate_name_candidate(cls, name: str) -> str | None: + """ + Validate an account name candidate. + + Returns one of several error codes, or `None` if all is okay: + + * ``blank``: No name supplied + * ``reserved``: Name is reserved + * ``invalid``: Invalid characters in name + * ``long``: Name is longer than allowed size + * ``user``: Name is assigned to a user + * ``org``: Name is assigned to an organization + """ + if not name: + return 'blank' + if name.lower() in cls.reserved_names: + return 'reserved' + if not valid_account_name(name): + return 'invalid' + if len(name) > cls.__name_length__: + return 'long' + # Look for existing on the base Account model, not the subclass, as SQLAlchemy + # will add a filter condition on subclasses to restrict the query to that type. + existing = ( + Account.query.filter(sa.func.lower(Account.name) == sa.func.lower(name)) + .options(sa.orm.load_only(cls.id, cls.uuid, cls.type_)) + .one_or_none() + ) + if existing is not None: + if isinstance(existing, Placeholder): + return 'reserved' + if isinstance(existing, User): + return 'user' + if isinstance(existing, Organization): + return 'org' + return None + + def validate_new_name(self, name: str) -> str | None: + """Validate a new name for this account, returning an error code or None.""" + if self.name and name.lower() == self.name.lower(): + return None + return self.validate_name_candidate(name) + + @classmethod + def is_available_name(cls, name: str) -> bool: + """Test if the candidate name is available for use as an Account name.""" + return cls.validate_name_candidate(name) is None + + @sa.orm.validates('name') + def _validate_name(self, key: str, value: str | None) -> str | None: + """Validate the value of Account.name.""" + if value is None: + return value + + if not isinstance(value, str): + raise ValueError(f"Account name must be a string: {value}") + + if not value.strip(): + raise ValueError("Account name cannot be blank") + + if value.lower() in self.reserved_names or not valid_account_name(value): + raise ValueError("Invalid account name: " + value) + + # We don't check for existence in the db since this validator only + # checks for valid syntax. To confirm the name is actually available, + # the caller must call :meth:`is_available_name` or attempt to commit + # to the db and catch IntegrityError. + return value + + @sa.orm.validates('logo_url', 'banner_image_url') + def _validate_nullable(self, key: str, value: str | None): + """Convert blank values into None.""" + return value if value else None + + @classmethod + def active_count(cls) -> int: + """Count of all active accounts.""" + return cls.query.filter(cls.state.ACTIVE).count() + + #: FIXME: Temporary values for Baseframe compatibility + def organization_links(self) -> list: + """Return list of organizations affiliated with this user (deprecated).""" + return [] + + # Make :attr:`type_` available under the name `type`, but declare this at the very + # end of the class to avoid conflicts with the Python `type` global that is + # used for type-hinting + type: Mapped[str] = sa.orm.synonym('type_') # noqa: A003 + + +auto_init_default(Account._state) # pylint: disable=protected-access +auto_init_default(Account._profile_state) # pylint: disable=protected-access +add_search_trigger(Account, 'search_vector') +add_search_trigger(Account, 'name_vector') + + +class AccountOldId(UuidMixin, BaseMixin, Model): + """Record of an older UUID for an account, after account merger.""" + + __tablename__ = 'account_oldid' + __uuid_primary_key__ = True + + #: Old account, if still present + old_account: Mapped[Account] = relationship( + Account, + primaryjoin='foreign(AccountOldId.id) == remote(Account.uuid)', + backref=backref('oldid', uselist=False), + ) + #: User id of new user + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + #: New account + account: Mapped[Account] = relationship( + Account, + foreign_keys=[account_id], + backref=backref('oldids', cascade='all'), + ) + + def __repr__(self) -> str: + """Represent :class:`AccountOldId` as a string.""" + return f'' + + @classmethod + def get(cls, uuid: UUID) -> AccountOldId | None: + """Get an old user record given a UUID.""" + return cls.query.filter_by(id=uuid).one_or_none() + + +class User(Account): + """User account.""" + + __mapper_args__ = {'polymorphic_identity': 'U'} + is_user_profile = True + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + if self.joined_at is None: + self.joined_at = sa.func.utcnow() + + +# XXX: Deprecated, still here for Baseframe compatibility +Account.userid = Account.uuid_b64 + + +class DuckTypeAccount(RoleMixin): + """User singleton constructor. Ducktypes a regular user object.""" + + id: None = None # noqa: A003 + created_at: None = None + updated_at: None = None + uuid: None = None + userid: None = None + buid: None = None + uuid_b58: None = None + username: None = None + name: None = None + profile_url: None = None + email: None = None + phone: None = None + + is_user_profile = True + is_organization_profile = False + is_placeholder_profile = False + + # Copy registries from Account model + views = Account.views + features = Account.features + forms = Account.forms + + __roles__ = { + 'all': { + 'read': { + 'id', + 'uuid', + 'username', + 'fullname', + 'pickername', + 'profile_url', + }, + 'call': {'views', 'forms', 'features', 'url_for'}, + } + } + + __datasets__ = { + 'related': { + 'username', + 'fullname', + 'pickername', + 'profile_url', + } + } + + #: Make obj.user/obj.posted_by from a referring object falsy + def __bool__(self) -> bool: + """Represent boolean state.""" + return False + + def __init__(self, representation: str) -> None: + self.fullname = self.title = self.pickername = representation + + def __str__(self) -> str: + return self.pickername + + def __format__(self, format_spec: str) -> str: + if not format_spec: + return self.pickername + return self.pickername.__format__(format_spec) + + def url_for(self, *args, **kwargs) -> Literal['']: + """Return blank URL for anything to do with this user.""" + return '' + + +deleted_account = DuckTypeAccount(__("[deleted]")) +removed_account = DuckTypeAccount(__("[removed]")) +unknown_account = DuckTypeAccount(__("[unknown]")) + + +# --- Organizations and teams ------------------------------------------------- + +team_membership = sa.Table( + 'team_membership', + Model.metadata, + sa.Column( + 'account_id', + sa.Integer, + sa.ForeignKey('account.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'team_id', + sa.Integer, + sa.ForeignKey('team.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'created_at', + sa.TIMESTAMP(timezone=True), + nullable=False, + default=sa.func.utcnow(), + ), +) + + +class Organization(Account): + """An organization of one or more users with distinct roles.""" + + __mapper_args__ = {'polymorphic_identity': 'O'} + is_organization_profile = True + + def __init__(self, owner: User, **kwargs) -> None: + super().__init__(**kwargs) + if self.joined_at is None: + self.joined_at = sa.func.utcnow() + db.session.add( + AccountMembership( + account=self, member=owner, granted_by=owner, is_owner=True + ) + ) + + def people(self) -> Query[Account]: + """Return a list of users from across the public teams they are in.""" + return ( + Account.query.join(team_membership) + .join(Team) + .filter(Team.account == self, Team.is_public.is_(True)) + .options(sa.orm.joinedload(Account.member_teams)) + .order_by(sa.func.lower(Account.title)) + ) + + +class Placeholder(Account): + """A placeholder account.""" + + __mapper_args__ = {'polymorphic_identity': 'P'} + is_placeholder_profile = True + + +class Team(UuidMixin, BaseMixin, Model): + """A team of users within an organization.""" + + __tablename__ = 'team' + __title_length__ = 250 + #: Displayed name + title: Mapped[str] = sa.orm.mapped_column( + sa.Unicode(__title_length__), nullable=False + ) + #: Organization + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False, index=True + ) + account = with_roles( + relationship( + Account, + foreign_keys=[account_id], + backref=backref('teams', order_by=sa.func.lower(title), cascade='all'), + ), + grants_via={None: {'owner': 'owner', 'admin': 'admin'}}, + ) + users: DynamicMapped[Account] = with_roles( + relationship( + Account, secondary=team_membership, lazy='dynamic', backref='member_teams' + ), + grants={'member'}, + ) + + is_public: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + + def __repr__(self) -> str: + """Represent :class:`Team` as a string.""" + return f'' + + @property + def pickername(self) -> str: + """Return team's title in a format suitable for identification.""" + return self.title + + @classmethod + def migrate_account( + cls, old_account: Account, new_account: Account + ) -> OptionalMigratedTables: + """Migrate one account's data to another when merging accounts.""" + for team in list(old_account.teams): + team.account = new_account + for team in list(old_account.member_teams): + if team not in new_account.member_teams: + # FIXME: This creates new memberships, updating `created_at`. + # Unfortunately, we can't work with model instances as in the other + # `migrate_account` methods as team_membership is an unmapped table. + new_account.member_teams.append(team) + old_account.member_teams.remove(team) + return [cls.__table__.name, team_membership.name] + + @classmethod + def get(cls, buid: str, with_parent: bool = False) -> Team | None: + """ + Return a Team with matching buid. + + :param str buid: Buid of the team + """ + if with_parent: + query = cls.query.options(sa.orm.joinedload(cls.account)) + else: + query = cls.query + return query.filter_by(buid=buid).one_or_none() + + +# --- Account email/phone and misc + + +class AccountEmail(EmailAddressMixin, BaseMixin, Model): + """An email address linked to an account.""" + + __tablename__ = 'account_email' + __email_optional__ = False + __email_unique__ = True + __email_is_exclusive__ = True + __email_for__ = 'account' + + # Tell mypy that these are not optional + email_address: Mapped[EmailAddress] # type: ignore[assignment] + + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship( + Account, backref=backref('emails', cascade='all') + ) + user: Mapped[Account] = sa.orm.synonym('account') + + private: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + + __datasets__ = { + 'primary': {'member', 'email', 'private', 'type'}, + 'without_parent': {'email', 'private', 'type'}, + 'related': {'email', 'private', 'type'}, + } + + def __init__(self, account: Account, **kwargs) -> None: + email = kwargs.pop('email', None) + if email: + kwargs['email_address'] = EmailAddress.add_for(account, email) + super().__init__(account=account, **kwargs) + + def __repr__(self) -> str: + """Represent this class as a string.""" + return f'' + + def __str__(self) -> str: # pylint: disable=invalid-str-returned + """Email address as a string.""" + return self.email or '' + + @property + def primary(self) -> bool: + """Check whether this email address is the user's primary.""" + return self.account.primary_email == self + + @primary.setter + def primary(self, value: bool) -> None: + """Set or unset this email address as primary.""" + if value: + self.account.primary_email = self + else: + if self.account.primary_email == self: + self.account.primary_email = None + + @overload + @classmethod + def get( + cls, + email: str, + ) -> AccountEmail | None: + ... + + @overload + @classmethod + def get( + cls, + *, + blake2b160: bytes, + ) -> AccountEmail | None: + ... + + @overload + @classmethod + def get( + cls, + *, + email_hash: str, + ) -> AccountEmail | None: + ... + + @classmethod + def get( + cls, + email: str | None = None, + *, + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> AccountEmail | None: + """ + Return an AccountEmail with matching email or blake2b160 hash. + + :param email: Email address to look up + :param blake2b160: 160-bit blake2b of email address to look up + :param email_hash: blake2b hash rendered in Base58 + """ + email_filter = EmailAddress.get_filter( + email=email, blake2b160=blake2b160, email_hash=email_hash + ) + if email_filter is None: + return None + return cls.query.join(EmailAddress).filter(email_filter).one_or_none() + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + email: str, + ) -> AccountEmail | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + blake2b160: bytes, + ) -> AccountEmail | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + email_hash: str, + ) -> AccountEmail | None: + ... + + @classmethod + def get_for( + cls, + account: Account, + *, + email: str | None = None, + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> AccountEmail | None: + """ + Return instance with matching email or hash if it belongs to the given user. + + :param user: Account to look up for + :param email: Email address to look up + :param blake2b160: 160-bit blake2b of email address + :param email_hash: blake2b hash rendered in Base58 + """ + email_filter = EmailAddress.get_filter( + email=email, blake2b160=blake2b160, email_hash=email_hash + ) + if email_filter is None: + return None + return ( + cls.query.join(EmailAddress) + .filter( + cls.account == account, + email_filter, + ) + .one_or_none() + ) + + @classmethod + def migrate_account( + cls, old_account: Account, new_account: Account + ) -> OptionalMigratedTables: + """Migrate one account's data to another when merging accounts.""" + primary_email = old_account.primary_email + for accountemail in list(old_account.emails): + accountemail.account = new_account + if new_account.primary_email is None: + new_account.primary_email = primary_email + old_account.primary_email = None + return [cls.__table__.name, user_email_primary_table.name] + + +class AccountEmailClaim(EmailAddressMixin, BaseMixin, Model): + """Claimed but unverified email address for a user.""" + + __tablename__ = 'account_email_claim' + __email_optional__ = False + __email_unique__ = False + __email_for__ = 'account' + __email_is_exclusive__ = False + + # Tell mypy that these are not optional + email_address: Mapped[EmailAddress] # type: ignore[assignment] + + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship( + Account, backref=backref('emailclaims', cascade='all') + ) + user: Mapped[Account] = sa.orm.synonym('account') + verification_code: Mapped[str] = sa.orm.mapped_column( + sa.String(44), nullable=False, default=newsecret + ) + + private: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + + __table_args__ = (sa.UniqueConstraint('account_id', 'email_address_id'),) + + __datasets__ = { + 'primary': {'member', 'email', 'private', 'type'}, + 'without_parent': {'email', 'private', 'type'}, + 'related': {'email', 'private', 'type'}, + } + + def __init__(self, account: Account, **kwargs) -> None: + email = kwargs.pop('email', None) + if email: + kwargs['email_address'] = EmailAddress.add_for(account, email) + super().__init__(account=account, **kwargs) + self.blake2b = hashlib.blake2b( + self.email.lower().encode(), digest_size=16 + ).digest() + + def __repr__(self) -> str: + """Represent this class as a string.""" + return f'' + + def __str__(self) -> str: + """Return email as a string.""" + return str(self.email) + + @classmethod + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" + emails = {claim.email for claim in new_account.emailclaims} + for claim in list(old_account.emailclaims): + if claim.email not in emails: + claim.account = new_account + else: + # New user also made the same claim. Delete old user's claim + db.session.delete(claim) + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + email: str, + ) -> AccountEmailClaim | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + blake2b160: bytes, + ) -> AccountEmailClaim | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + email_hash: str, + ) -> AccountEmailClaim | None: + ... + + @classmethod + def get_for( + cls, + account: Account, + *, + email: str | None = None, + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> AccountEmailClaim | None: + """ + Return an AccountEmailClaim with matching email address for the given user. + + :param account: Account that claimed this email address + :param email: Email address to look up + :param blake2b160: 160-bit blake2b of email address to look up + :param email_hash: Base58 rendering of 160-bit blake2b hash + """ + email_filter = EmailAddress.get_filter( + email=email, blake2b160=blake2b160, email_hash=email_hash + ) + if email_filter is None: + return None + return ( + cls.query.join(EmailAddress) + .filter( + cls.account == account, + email_filter, + ) + .one_or_none() + ) + + @overload + @classmethod + def get_by( + cls, + verification_code: str, + *, + email: str, + ) -> AccountEmailClaim | None: + ... + + @overload + @classmethod + def get_by( + cls, + verification_code: str, + *, + blake2b160: bytes, + ) -> AccountEmailClaim | None: + ... + + @overload + @classmethod + def get_by( + cls, + verification_code: str, + *, + email_hash: str, + ) -> AccountEmailClaim | None: + ... + + @classmethod + def get_by( + cls, + verification_code: str, + *, + email: str | None = None, + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> AccountEmailClaim | None: + """Return an instance given verification code and email or hash.""" + email_filter = EmailAddress.get_filter( + email=email, blake2b160=blake2b160, email_hash=email_hash + ) + if email_filter is None: + return None + return ( + cls.query.join(EmailAddress) + .filter( + cls.verification_code == verification_code, + email_filter, + ) + .one_or_none() + ) + + @classmethod + def all(cls, email: str) -> Query[AccountEmailClaim]: # noqa: A003 + """ + Return all instances with the matching email address. + + :param str email: Email address to lookup + """ + email_filter = EmailAddress.get_filter(email=email) + if email_filter is None: + raise ValueError(email) + return cls.query.join(EmailAddress).filter(email_filter) + + +auto_init_default(AccountEmailClaim.verification_code) + + +class AccountPhone(PhoneNumberMixin, BaseMixin, Model): + """A phone number linked to an account.""" + + __tablename__ = 'account_phone' + __phone_optional__ = False + __phone_unique__ = True + __phone_is_exclusive__ = True + __phone_for__ = 'account' + + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship( + Account, backref=backref('phones', cascade='all') + ) + user: Mapped[Account] = sa.orm.synonym('account') + + private: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + + __datasets__ = { + 'primary': {'member', 'phone', 'private', 'type'}, + 'without_parent': {'phone', 'private', 'type'}, + 'related': {'phone', 'private', 'type'}, + } + + def __init__(self, account, **kwargs) -> None: + phone = kwargs.pop('phone', None) + if phone: + kwargs['phone_number'] = PhoneNumber.add_for(account, phone) + super().__init__(account=account, **kwargs) + + def __repr__(self) -> str: + """Represent this class as a string.""" + return f'AccountPhone(phone={self.phone!r}, account={self.account!r})' + + def __str__(self) -> str: + """Return phone number as a string.""" + return self.phone or '' + + @cached_property + def parsed(self) -> phonenumbers.PhoneNumber: + """Return parsed phone number using libphonenumbers.""" + return self.phone_number.parsed + + @cached_property + def formatted(self) -> str: + """Return a phone number formatted for user display.""" + return self.phone_number.formatted + + @property + def number(self) -> str | None: + return self.phone_number.number + + @property + def primary(self) -> bool: + """Check if this is the user's primary phone number.""" + return self.account.primary_phone == self + + @primary.setter + def primary(self, value: bool) -> None: + if value: + self.account.primary_phone = self + else: + if self.account.primary_phone == self: + self.account.primary_phone = None + + @overload + @classmethod + def get( + cls, + phone: str, + ) -> AccountPhone | None: + ... + + @overload + @classmethod + def get( + cls, + *, + blake2b160: bytes, + ) -> AccountPhone | None: + ... + + @overload + @classmethod + def get( + cls, + *, + phone_hash: str, + ) -> AccountPhone | None: + ... + + @classmethod + def get( + cls, + phone: str | None = None, + *, + blake2b160: bytes | None = None, + phone_hash: str | None = None, + ) -> AccountPhone | None: + """ + Return an AccountPhone with matching phone number. + + :param phone: Phone number to lookup + :param blake2b160: 160-bit blake2b of phone number to look up + :param phone_hash: blake2b hash rendered in Base58 + """ + return ( + cls.query.join(PhoneNumber) + .filter( + PhoneNumber.get_filter( + phone=phone, blake2b160=blake2b160, phone_hash=phone_hash + ) + ) + .one_or_none() + ) + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + phone: str, + ) -> AccountPhone | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + blake2b160: bytes, + ) -> AccountPhone | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + phone_hash: str, + ) -> AccountPhone | None: + ... + + @classmethod + def get_for( + cls, + account: Account, + *, + phone: str | None = None, + blake2b160: bytes | None = None, + phone_hash: str | None = None, + ) -> AccountPhone | None: + """ + Return an instance with matching phone or hash if it belongs to the given user. + + :param account: Account to look up for + :param phone: Email address to look up + :param blake2b160: 160-bit blake2b of phone number + :param phone_hash: blake2b hash rendered in Base58 + """ + return ( + cls.query.join(PhoneNumber) + .filter( + cls.account == account, + PhoneNumber.get_filter( + phone=phone, blake2b160=blake2b160, phone_hash=phone_hash + ), + ) + .one_or_none() + ) + + @classmethod + def migrate_account( + cls, old_account: Account, new_account: Account + ) -> OptionalMigratedTables: + """Migrate one account's data to another when merging accounts.""" + primary_phone = old_account.primary_phone + for accountphone in list(old_account.phones): + accountphone.account = new_account + if new_account.primary_phone is None: + new_account.primary_phone = primary_phone + old_account.primary_phone = None + return [cls.__table__.name, user_phone_primary_table.name] + + +class AccountExternalId(BaseMixin, Model): + """An external connected account for a user.""" + + __tablename__ = 'account_externalid' + __at_username_services__: ClassVar[list[str]] = [] + #: Foreign key to user table + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + #: User that this connected account belongs to + account: Mapped[Account] = relationship( + Account, backref=backref('externalids', cascade='all') + ) + user: Mapped[Account] = sa.orm.synonym('account') + #: Identity of the external service (in app's login provider registry) + # FIXME: change to sa.Unicode + service: Mapped[str] = sa.orm.mapped_column(sa.UnicodeText, nullable=False) + #: Unique user id as per external service, used for identifying related accounts + # FIXME: change to sa.Unicode + userid: Mapped[str] = sa.orm.mapped_column( + sa.UnicodeText, nullable=False + ) # Unique id (or obsolete OpenID) + #: Optional public-facing username on the external service + # FIXME: change to sa.Unicode + username: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) # LinkedIn once used full URLs + #: OAuth or OAuth2 access token + # FIXME: change to sa.Unicode + oauth_token: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) + #: Optional token secret (not used in OAuth2, used by Twitter with OAuth1a) + # FIXME: change to sa.Unicode + oauth_token_secret: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) + #: OAuth token type (typically 'bearer') + # FIXME: change to sa.Unicode + oauth_token_type: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) + #: OAuth2 refresh token + # FIXME: change to sa.Unicode + oauth_refresh_token: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) + #: OAuth2 token expiry in seconds, as sent by service provider + oauth_expires_in: Mapped[int | None] = sa.orm.mapped_column( + sa.Integer, nullable=True + ) + #: OAuth2 token expiry timestamp, estimate from created_at + oauth_expires_in + oauth_expires_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True, index=True + ) + + #: Timestamp of when this connected account was last (re-)authorised by the user + last_used_at: Mapped[datetime] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), default=sa.func.utcnow(), nullable=False + ) + + __table_args__ = ( + sa.UniqueConstraint('service', 'userid'), + sa.Index( + 'ix_account_externalid_username_lower', + sa.func.lower(username).label('username_lower'), + postgresql_ops={'username_lower': 'varchar_pattern_ops'}, + ), + ) + + def __repr__(self) -> str: + """Represent :class:`UserExternalId` as a string.""" + return f'' + + @overload + @classmethod + def get( + cls, + service: str, + *, + userid: str, + ) -> AccountExternalId | None: + ... + + @overload + @classmethod + def get( + cls, + service: str, + *, + username: str, + ) -> AccountExternalId | None: + ... + + @classmethod + def get( + cls, + service: str, + *, + userid: str | None = None, + username: str | None = None, + ) -> AccountExternalId | None: + """ + Return a UserExternalId with the given service and userid or username. + + :param str service: Service to lookup + :param str userid: Userid to lookup + :param str username: Username to lookup (may be non-unique) + + Usernames are not guaranteed to be unique within a service. An example is with + Google, where the userid is a directed OpenID URL, unique but subject to change + if the Lastuser site URL changes. The username is the email address, which will + be the same despite different userids. + """ + param, value = require_one_of(True, userid=userid, username=username) + return cls.query.filter_by(**{param: value, 'service': service}).one_or_none() + + +user_email_primary_table = add_primary_relationship( + Account, 'primary_email', AccountEmail, 'account', 'account_id' +) +user_phone_primary_table = add_primary_relationship( + Account, 'primary_phone', AccountPhone, 'account', 'account_id' +) + +#: Anchor type +Anchor = Union[AccountEmail, AccountEmailClaim, AccountPhone, EmailAddress, PhoneNumber] + +# Tail imports +# pylint: disable=wrong-import-position +from .membership_mixin import ImmutableMembershipMixin # isort: skip +from .account_membership import AccountMembership # isort:skip diff --git a/funnel/models/account_membership.py b/funnel/models/account_membership.py new file mode 100644 index 000000000..80c4b97b7 --- /dev/null +++ b/funnel/models/account_membership.py @@ -0,0 +1,234 @@ +"""Membership model for admins of an organization.""" + +from __future__ import annotations + +from werkzeug.utils import cached_property + +from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles + +from . import DynamicMapped, Mapped, Model, backref, relationship, sa +from .account import Account +from .helpers import reopen +from .membership_mixin import ImmutableUserMembershipMixin + +__all__ = ['AccountMembership'] + + +class AccountMembership(ImmutableUserMembershipMixin, Model): + """ + An account can be a member of another account as an owner, admin or follower. + + Owners can manage other administrators. + + TODO: This model may introduce non-admin memberships in a future iteration by + replacing :attr:`is_owner` with :attr:`member_level` or distinct role flags as in + :class:`ProjectMembership`. + """ + + __tablename__ = 'account_membership' + + # Legacy data has no granted_by + __null_granted_by__ = True + + #: List of role columns in this model + __data_columns__ = ('is_owner',) + + __roles__ = { + 'all': { + 'read': { + 'urls', + 'member', + 'is_owner', + 'account', + 'granted_by', + 'revoked_by', + 'granted_at', + 'revoked_at', + 'is_self_granted', + 'is_self_revoked', + } + }, + 'account_admin': { + 'read': { + 'record_type', + 'record_type_label', + 'granted_at', + 'granted_by', + 'revoked_at', + 'revoked_by', + 'member', + 'is_active', + 'is_invite', + 'is_self_granted', + 'is_self_revoked', + } + }, + } + __datasets__ = { + 'primary': { + 'urls', + 'uuid_b58', + 'offered_roles', + 'is_owner', + 'member', + 'account', + }, + 'without_parent': {'urls', 'uuid_b58', 'offered_roles', 'is_owner', 'member'}, + 'related': {'urls', 'uuid_b58', 'offered_roles', 'is_owner'}, + } + + #: Organization that this membership is being granted on + account_id: Mapped[int] = sa.orm.mapped_column( + sa.Integer, + sa.ForeignKey('account.id', ondelete='CASCADE'), + nullable=False, + ) + account: Mapped[Account] = with_roles( + relationship( + Account, + foreign_keys=[account_id], + backref=backref( + 'memberships', lazy='dynamic', cascade='all', passive_deletes=True + ), + ), + grants_via={None: {'admin': 'account_admin', 'owner': 'account_owner'}}, + ) + parent_id: Mapped[int] = sa.orm.synonym('account_id') + parent_id_column = 'account_id' + parent: Mapped[Account] = sa.orm.synonym('account') + + # Organization roles: + is_owner: Mapped[bool] = immutable( + sa.orm.mapped_column(sa.Boolean, nullable=False, default=False) + ) + + @cached_property + def offered_roles(self) -> set[str]: + """Roles offered by this membership record.""" + roles = {'admin'} + if self.is_owner: + roles.add('owner') + return roles + + +# Add active membership relationships to Account +@reopen(Account) +class __Account: + active_admin_memberships: DynamicMapped[AccountMembership] = with_roles( + relationship( + AccountMembership, + lazy='dynamic', + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.account_id) == Account.id, + AccountMembership.is_active, + ), + order_by=AccountMembership.granted_at.asc(), + viewonly=True, + ), + grants_via={'member': {'admin', 'owner'}}, + ) + + active_owner_memberships: DynamicMapped[AccountMembership] = relationship( + AccountMembership, + lazy='dynamic', + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.account_id) == Account.id, + AccountMembership.is_active, + AccountMembership.is_owner.is_(True), + ), + viewonly=True, + ) + + active_invitations: DynamicMapped[AccountMembership] = relationship( + AccountMembership, + lazy='dynamic', + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.account_id) == Account.id, + AccountMembership.is_invite, + AccountMembership.revoked_at.is_(None), + ), + viewonly=True, + ) + + owner_users = with_roles( + DynamicAssociationProxy('active_owner_memberships', 'member'), read={'all'} + ) + admin_users = with_roles( + DynamicAssociationProxy('active_admin_memberships', 'member'), read={'all'} + ) + + # pylint: disable=invalid-unary-operand-type + organization_admin_memberships: DynamicMapped[AccountMembership] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], # type: ignore[has-type] + viewonly=True, + ) + + noninvite_organization_admin_memberships: DynamicMapped[ + AccountMembership + ] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] + == Account.id, + ~AccountMembership.is_invite, + ), + viewonly=True, + ) + + active_organization_admin_memberships: DynamicMapped[ + AccountMembership + ] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] + == Account.id, + AccountMembership.is_active, + ), + viewonly=True, + ) + + active_organization_owner_memberships: DynamicMapped[ + AccountMembership + ] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] + == Account.id, + AccountMembership.is_active, + AccountMembership.is_owner.is_(True), + ), + viewonly=True, + ) + + active_organization_invitations: DynamicMapped[AccountMembership] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] + == Account.id, + AccountMembership.is_invite, + AccountMembership.revoked_at.is_(None), + ), + viewonly=True, + ) + + organizations_as_owner = DynamicAssociationProxy( + 'active_organization_owner_memberships', 'account' + ) + + organizations_as_admin = DynamicAssociationProxy( + 'active_organization_admin_memberships', 'account' + ) + + +Account.__active_membership_attrs__.add('active_organization_admin_memberships') +Account.__noninvite_membership_attrs__.add('noninvite_organization_admin_memberships') diff --git a/funnel/models/auth_client.py b/funnel/models/auth_client.py index d3da5b49b..cf8281010 100644 --- a/funnel/models/auth_client.py +++ b/funnel/models/auth_client.py @@ -2,37 +2,37 @@ from __future__ import annotations +import urllib.parse +from collections.abc import Iterable, Sequence from datetime import datetime, timedelta from hashlib import blake2b, sha256 -from typing import ( - Dict, - Iterable, - List, - Optional, - Sequence, - Tuple, - Union, - cast, - overload, -) -import urllib.parse +from typing import cast, overload -from sqlalchemy.orm import load_only -from sqlalchemy.orm.collections import attribute_mapped_collection +from sqlalchemy.orm import attribute_keyed_dict, load_only from sqlalchemy.orm.query import Query as QueryBaseClass - from werkzeug.utils import cached_property from baseframe import _ from coaster.sqlalchemy import with_roles -from coaster.utils import buid as make_buid -from coaster.utils import newsecret, require_one_of, utcnow +from coaster.utils import buid as make_buid, newsecret, require_one_of, utcnow -from ..typing import OptionalMigratedTables -from . import BaseMixin, Mapped, UuidMixin, db, declarative_mixin, declared_attr, sa +from . import ( + BaseMixin, + DynamicMapped, + Mapped, + Model, + Query, + UuidMixin, + backref, + db, + declarative_mixin, + declared_attr, + relationship, + sa, +) +from .account import Account, Team from .helpers import reopen -from .user import Organization, Team, User -from .user_session import UserSession, auth_client_user_session +from .login_session import LoginSession, auth_client_login_session __all__ = [ 'AuthCode', @@ -40,7 +40,7 @@ 'AuthClient', 'AuthClientCredential', 'AuthClientTeamPermissions', - 'AuthClientUserPermissions', + 'AuthClientPermissions', ] @@ -51,115 +51,96 @@ class ScopeMixin: __scope_null_allowed__ = False @declared_attr - def _scope(cls) -> sa.Column[sa.UnicodeText]: # pylint: disable=no-self-argument + @classmethod + def _scope(cls) -> Mapped[str]: """Database column for storing scopes as a space-separated string.""" - return sa.Column( - 'scope', - sa.UnicodeText, # type: ignore[arg-type] - nullable=cls.__scope_null_allowed__, + return sa.orm.mapped_column( + 'scope', sa.UnicodeText, nullable=cls.__scope_null_allowed__ ) - @declared_attr - def scope( # pylint: disable=no-self-argument - cls, - ) -> Mapped[Tuple[str, ...]]: + @property + def scope(self) -> Iterable[str]: """Represent scope column as a container of strings.""" - # pylint: disable=protected-access - def scope_get(self) -> Tuple[str, ...]: - if not self._scope: - return () - return tuple(sorted(self._scope.split())) - - def scope_set(self, value: Optional[Union[str, Iterable]]) -> None: - if value is None: - if self.__scope_null_allowed__: - self._scope = None - return - raise ValueError("Scope cannot be None") - if isinstance(value, str): - value = value.split() - self._scope = ' '.join(sorted(t.strip() for t in value if t)) - if not self._scope and self.__scope_null_allowed__: - self._scope = None - - # pylint: enable=protected-access - return sa.orm.synonym('_scope', descriptor=property(scope_get, scope_set)) + if not self._scope: + return () + return tuple(sorted(self._scope.split())) - def add_scope(self, additional: Union[str, Iterable]) -> None: + @scope.setter + def scope(self, value: str | Iterable | None) -> None: + if value is None: + if self.__scope_null_allowed__: + self._scope = None + return + raise ValueError("Scope cannot be None") + if isinstance(value, str): + value = value.split() + self._scope = ' '.join(sorted(t.strip() for t in value if t)) + if not self._scope and self.__scope_null_allowed__: + self._scope = None + + def add_scope(self, additional: str | Iterable) -> None: """Add additional items to the scope.""" if isinstance(additional, str): additional = [additional] self.scope = set(self.scope).union(set(additional)) -class AuthClient( - ScopeMixin, - UuidMixin, - BaseMixin, - db.Model, # type: ignore[name-defined] -): +class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): """OAuth client application.""" __tablename__ = 'auth_client' __scope_null_allowed__ = True - # TODO: merge columns into a profile_id column - #: User who owns this client - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) - user: Mapped[Optional[User]] = with_roles( - sa.orm.relationship( - User, - primaryjoin=user_id == User.id, - backref=sa.orm.backref('clients', cascade='all'), + #: Account that owns this client + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=True + ) + account: Mapped[Account | None] = with_roles( + relationship( + Account, + foreign_keys=[account_id], + backref=backref('clients', cascade='all'), ), read={'all'}, write={'owner'}, - grants={'owner'}, - ) - #: Organization that owns this client. Only one of this or user must be set - organization_id = sa.Column( - sa.Integer, sa.ForeignKey('organization.id'), nullable=True - ) - organization: Mapped[Optional[User]] = with_roles( - sa.orm.relationship( - Organization, - primaryjoin=organization_id == Organization.id, - backref=sa.orm.backref('clients', cascade='all'), - ), - read={'all'}, - write={'owner'}, - grants_via={None: {'owner': 'owner', 'admin': 'owner'}}, + grants_via={None: {'owner': 'owner', 'admin': 'admin'}}, ) #: Human-readable title title = with_roles( - sa.Column(sa.Unicode(250), nullable=False), read={'all'}, write={'owner'} + sa.orm.mapped_column(sa.Unicode(250), nullable=False), + read={'all'}, + write={'owner'}, ) #: Long description description = with_roles( - sa.Column(sa.UnicodeText, nullable=False, default=''), + sa.orm.mapped_column(sa.UnicodeText, nullable=False, default=''), read={'all'}, write={'owner'}, ) #: Confidential or public client? Public has no secret key confidential = with_roles( - sa.Column(sa.Boolean, nullable=False), read={'all'}, write={'owner'} + sa.orm.mapped_column(sa.Boolean, nullable=False), read={'all'}, write={'owner'} ) #: Website website = with_roles( - sa.Column(sa.UnicodeText, nullable=False), read={'all'}, write={'owner'} + sa.orm.mapped_column(sa.UnicodeText, nullable=False), + read={'all'}, + write={'owner'}, ) #: Redirect URIs (one or more) - _redirect_uris = sa.Column( + _redirect_uris: Mapped[str | None] = sa.orm.mapped_column( 'redirect_uri', sa.UnicodeText, nullable=True, default='' ) #: Back-end notification URI (TODO: deprecated, needs better architecture) - notification_uri: sa.Column[Optional[str]] = with_roles( - sa.Column(sa.UnicodeText, nullable=True, default=''), rw={'owner'} + notification_uri = with_roles( + sa.orm.mapped_column(sa.UnicodeText, nullable=True, default=''), rw={'owner'} ) #: Active flag - active = sa.Column(sa.Boolean, nullable=False, default=True) + active: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=True + ) #: Allow anyone to login to this app? allow_any_login = with_roles( - sa.Column(sa.Boolean, nullable=False, default=True), + sa.orm.mapped_column(sa.Boolean, nullable=False, default=True), read={'all'}, write={'owner'}, ) @@ -170,23 +151,14 @@ class AuthClient( #: However, resources in the scope column (via ScopeMixin) are granted for #: any arbitrary user without explicit user authorization. trusted = with_roles( - sa.Column(sa.Boolean, nullable=False, default=False), read={'all'} + sa.orm.mapped_column(sa.Boolean, nullable=False, default=False), read={'all'} ) - user_sessions = sa.orm.relationship( - UserSession, + login_sessions: DynamicMapped[LoginSession] = relationship( + LoginSession, lazy='dynamic', - secondary=auth_client_user_session, - backref=sa.orm.backref('auth_clients', lazy='dynamic'), - ) - - __table_args__ = ( - sa.CheckConstraint( - sa.case([(user_id.isnot(None), 1)], else_=0) - + sa.case([(organization_id.isnot(None), 1)], else_=0) - == 1, - name='auth_client_owner_check', - ), + secondary=auth_client_login_session, + backref=backref('auth_clients', lazy='dynamic'), ) __roles__ = { @@ -206,7 +178,7 @@ def secret_is(self, candidate: str, name: str) -> bool: return credential.secret_is(candidate) @property - def redirect_uris(self) -> Tuple: + def redirect_uris(self) -> Iterable[str]: """Return redirect URIs as a sequence.""" return tuple(self._redirect_uris.split()) if self._redirect_uris else () @@ -218,7 +190,7 @@ def redirect_uris(self, value: Iterable) -> None: with_roles(redirect_uris, rw={'owner'}) @property - def redirect_uri(self) -> Optional[str]: + def redirect_uri(self) -> str | None: """Return the first redirect URI, if present.""" uris = self.redirect_uris # Assign to local var to avoid splitting twice if uris: @@ -235,48 +207,41 @@ def host_matches(self, url: str) -> bool: ) return False - @property - def owner(self): - """Return user or organization that owns this client app.""" - return self.user or self.organization - - with_roles(owner, read={'all'}) - - def owner_is(self, user: User) -> bool: - """Test if the provided user is an owner of this client.""" + def owner_is(self, account: Account | None) -> bool: + """Test if the provided account is an owner of this client.""" # Legacy method for ownership test - return 'owner' in self.roles_for(user) + return account is not None and 'owner' in self.roles_for(account) def authtoken_for( - self, user: Optional[User], user_session: Optional[UserSession] = None - ) -> Optional[AuthToken]: + self, account: Account | None, login_session: LoginSession | None = None + ) -> AuthToken | None: """ - Return the authtoken for this user and client. + Return the authtoken for this account and client. Only works for confidential clients. """ if self.confidential: - if user is None: - raise ValueError("User not provided") - return AuthToken.get_for(auth_client=self, user=user) - if user_session and user_session.user == user: - return AuthToken.get_for(auth_client=self, user_session=user_session) + if account is None: + raise ValueError("Account not provided") + return AuthToken.get_for(auth_client=self, account=account) + if login_session and login_session.account == account: + return AuthToken.get_for(auth_client=self, login_session=login_session) return None - def allow_access_for(self, actor: User) -> bool: + def allow_access_for(self, actor: Account) -> bool: """Test if access is allowed for this user as per the auth client settings.""" if self.allow_any_login: return True - if self.user: - if AuthClientUserPermissions.get(self, actor): + if self.account: + if AuthClientPermissions.get(self, actor): return True else: - if AuthClientTeamPermissions.all_for(self, actor).first(): + if AuthClientTeamPermissions.all_for(self, actor).notempty(): return True return False @classmethod - def get(cls, buid: str) -> Optional[AuthClient]: + def get(cls, buid: str) -> AuthClient | None: """ Return a AuthClient identified by its client buid or namespace. @@ -284,30 +249,30 @@ def get(cls, buid: str) -> Optional[AuthClient]: :param str buid: AuthClient buid to lookup """ - return cls.query.filter_by(buid=buid, active=True).one_or_none() + return cls.query.filter(cls.buid == buid, cls.active.is_(True)).one_or_none() @classmethod - def all_for(cls, user: Optional[User]) -> QueryBaseClass: - """Return all clients, optionally all clients owned by the specified user.""" - if user is None: + def all_for(cls, account: Account | None) -> Query[AuthClient]: + """Return all clients, optionally all clients owned by the specified account.""" + if account is None: return cls.query.order_by(cls.title) return cls.query.filter( sa.or_( - cls.user == user, - cls.organization_id.in_(user.organizations_as_owner_ids()), + cls.account == account, + cls.account_id.in_(account.organizations_as_owner_ids()), ) ).order_by(cls.title) -class AuthClientCredential(BaseMixin, db.Model): # type: ignore[name-defined] +class AuthClientCredential(BaseMixin, Model): """ AuthClient key and secret hash. This uses unsalted Blake2 (64-bit) instead of a salted hash or a more secure hash like bcrypt because: - 1. Secrets are UUID-based and unique before hashing. Salting is only beneficial when - the source values may be reused. + 1. Secrets are random and unique before hashing. Salting is only beneficial when + the secrets may be reused. 2. Unlike user passwords, client secrets are used often, up to many times per minute. The hash needs to be fast (MD5 or SHA) and reasonably safe from collision attacks (eliminating MD5, SHA0 and SHA1). Blake2 is the fastest available @@ -319,33 +284,43 @@ class AuthClientCredential(BaseMixin, db.Model): # type: ignore[name-defined] """ __tablename__ = 'auth_client_credential' - auth_client_id = sa.Column( + auth_client_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False ) - auth_client: AuthClient = with_roles( - sa.orm.relationship( + auth_client: Mapped[AuthClient] = with_roles( + relationship( AuthClient, - primaryjoin=auth_client_id == AuthClient.id, - backref=sa.orm.backref( + backref=backref( 'credentials', - cascade='all', - collection_class=attribute_mapped_collection('name'), + cascade='all, delete-orphan', + collection_class=attribute_keyed_dict('name'), ), ), grants_via={None: {'owner'}}, ) #: OAuth client key - name = sa.Column(sa.String(22), nullable=False, unique=True, default=make_buid) + name: Mapped[str] = sa.orm.mapped_column( + sa.String(22), nullable=False, unique=True, default=make_buid + ) #: User description for this credential - title = sa.Column(sa.Unicode(250), nullable=False, default='') + title: Mapped[str] = sa.orm.mapped_column( + sa.Unicode(250), nullable=False, default='' + ) #: OAuth client secret, hashed - secret_hash = sa.Column(sa.Unicode, nullable=False) + secret_hash: Mapped[str] = sa.orm.mapped_column(sa.Unicode, nullable=False) #: When was this credential last used for an API call? - accessed_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + accessed_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + + def __repr__(self) -> str: + return f'' - def secret_is(self, candidate: str, upgrade_hash: bool = False): + def secret_is(self, candidate: str | None, upgrade_hash: bool = False) -> bool: """Test if the candidate secret matches.""" + if not candidate: + return False if self.secret_hash.startswith('blake2b$32$'): return ( self.secret_hash @@ -366,12 +341,12 @@ def secret_is(self, candidate: str, upgrade_hash: bool = False): return False @classmethod - def get(cls, name: str): + def get(cls, name: str) -> AuthClientCredential | None: """Get a client credential by its key name.""" - return cls.query.filter_by(name=name).one_or_none() + return cls.query.filter(cls.name == name).one_or_none() @classmethod - def new(cls, auth_client: AuthClient): + def new(cls, auth_client: AuthClient) -> tuple[AuthClientCredential, str]: """ Create a new client credential and return (cred, secret). @@ -380,35 +355,43 @@ def new(cls, auth_client: AuthClient): :param auth_client: The client for which a name/secret pair is being generated """ - cred = cls(auth_client=auth_client, name=make_buid()) secret = newsecret() - cred.secret_hash = ( - 'blake2b$32$' + blake2b(secret.encode(), digest_size=32).hexdigest() + cred = cls( + name=make_buid(), + secret_hash=( + 'blake2b$32$' + blake2b(secret.encode(), digest_size=32).hexdigest() + ), + auth_client=auth_client, ) + db.session.add(cred) return cred, secret -class AuthCode(ScopeMixin, BaseMixin, db.Model): # type: ignore[name-defined] +class AuthCode(ScopeMixin, BaseMixin, Model): """Short-lived authorization tokens.""" __tablename__ = 'auth_code' - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user = sa.orm.relationship(User, primaryjoin=user_id == User.id) - auth_client_id = sa.Column( + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship(Account, foreign_keys=[account_id]) + auth_client_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False ) - auth_client = sa.orm.relationship( + auth_client: Mapped[AuthClient] = relationship( AuthClient, - primaryjoin=auth_client_id == AuthClient.id, - backref=sa.orm.backref('authcodes', cascade='all'), + foreign_keys=[auth_client_id], + backref=backref('authcodes', cascade='all'), + ) + login_session_id: Mapped[int | None] = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('login_session.id'), nullable=True ) - user_session_id = sa.Column( - sa.Integer, sa.ForeignKey('user_session.id'), nullable=True + login_session: Mapped[LoginSession | None] = relationship(LoginSession) + code: Mapped[str] = sa.orm.mapped_column( + sa.String(44), default=newsecret, nullable=False ) - user_session: Mapped[Optional[UserSession]] = sa.orm.relationship(UserSession) - code = sa.Column(sa.String(44), default=newsecret, nullable=False) - redirect_uri = sa.Column(sa.UnicodeText, nullable=False) - used = sa.Column(sa.Boolean, default=False, nullable=False) + redirect_uri: Mapped[str] = sa.orm.mapped_column(sa.UnicodeText, nullable=False) + used: Mapped[bool] = sa.orm.mapped_column(sa.Boolean, default=False, nullable=False) def is_valid(self) -> bool: """Test if this auth code is still valid.""" @@ -417,108 +400,98 @@ def is_valid(self) -> bool: return not self.used and self.created_at >= utcnow() - timedelta(minutes=3) @classmethod - def all_for(cls, user: User) -> QueryBaseClass: - """Return all auth codes for the specified user.""" - return cls.query.filter_by(user=user) + def all_for(cls, account: Account) -> Query[AuthCode]: + """Return all auth codes for the specified account.""" + return cls.query.filter(cls.account == account) @classmethod - def get_for_client(cls, auth_client: AuthClient, code: str) -> Optional[AuthCode]: + def get_for_client(cls, auth_client: AuthClient, code: str) -> AuthCode | None: """Return a matching auth code for the specified auth client.""" - return cls.query.filter_by(auth_client=auth_client, code=code).one_or_none() + return cls.query.filter( + cls.auth_client == auth_client, cls.code == code + ).one_or_none() -class AuthToken(ScopeMixin, BaseMixin, db.Model): # type: ignore[name-defined] +class AuthToken(ScopeMixin, BaseMixin, Model): """Access tokens for access to data.""" __tablename__ = 'auth_token' - # User id is null for client-only tokens and public clients as the user is - # identified via user_session.user there - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) - _user: Mapped[Optional[User]] = sa.orm.relationship( - User, - primaryjoin=user_id == User.id, - backref=sa.orm.backref('authtokens', lazy='dynamic', cascade='all'), + # Account id is null for client-only tokens and public clients as the account is + # identified via login_session.account there + account_id: Mapped[int | None] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=True + ) + account: Mapped[Account | None] = relationship( + Account, + backref=backref('authtokens', lazy='dynamic', cascade='all'), ) #: The session in which this token was issued, null for confidential clients - user_session_id = sa.Column( - sa.Integer, sa.ForeignKey('user_session.id'), nullable=True + login_session_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('login_session.id'), nullable=True ) - user_session: Mapped[Optional[UserSession]] = with_roles( - sa.orm.relationship( - UserSession, backref=sa.orm.backref('authtokens', lazy='dynamic') - ), + login_session: Mapped[LoginSession | None] = with_roles( + relationship(LoginSession, backref=backref('authtokens', lazy='dynamic')), read={'owner'}, ) #: The client this authtoken is for - auth_client_id = sa.Column( + auth_client_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True ) - auth_client: sa.orm.relationship[AuthClient] = with_roles( - sa.orm.relationship( + auth_client: Mapped[AuthClient] = with_roles( + relationship( AuthClient, - primaryjoin=auth_client_id == AuthClient.id, - backref=sa.orm.backref('authtokens', lazy='dynamic', cascade='all'), + backref=backref('authtokens', lazy='dynamic', cascade='all'), ), read={'owner'}, ) #: The token - token = sa.Column(sa.String(22), default=make_buid, nullable=False, unique=True) - #: The token's type - token_type = sa.Column( - sa.String(250), default='bearer', nullable=False - ) # 'bearer', 'mac' or a URL + token = sa.orm.mapped_column( + sa.String(22), default=make_buid, nullable=False, unique=True + ) + #: The token's type, 'bearer', 'mac' or a URL + token_type = sa.orm.mapped_column(sa.String(250), default='bearer', nullable=False) #: Token secret for 'mac' type - secret = sa.Column(sa.String(44), nullable=True) + secret = sa.orm.mapped_column(sa.String(44), nullable=True) #: Secret's algorithm (for 'mac' type) - _algorithm = sa.Column('algorithm', sa.String(20), nullable=True) - #: Token's validity, 0 = unlimited - validity = sa.Column( - sa.Integer, nullable=False, default=0 - ) # Validity period in seconds + algorithm = sa.orm.mapped_column(sa.String(20), nullable=True) + #: Token's validity period in seconds, 0 = unlimited + validity = sa.orm.mapped_column(sa.Integer, nullable=False, default=0) #: Refresh token, to obtain a new token - refresh_token = sa.Column(sa.String(22), nullable=True, unique=True) + refresh_token = sa.orm.mapped_column(sa.String(22), nullable=True, unique=True) # Only one authtoken per user and client. Add to scope as needed __table_args__ = ( - sa.UniqueConstraint('user_id', 'auth_client_id'), - sa.UniqueConstraint('user_session_id', 'auth_client_id'), + sa.UniqueConstraint('account_id', 'auth_client_id'), + sa.UniqueConstraint('login_session_id', 'auth_client_id'), ) __roles__ = { 'owner': { - 'read': {'created_at', 'user'}, - 'granted_by': ['user'], + 'read': {'created_at', 'account'}, + 'granted_by': ['account'], } } @property - def user(self) -> User: + def effective_user(self) -> Account: """Return subject user of this auth token.""" - if self.user_session: - return self.user_session.user - return self._user - - @user.setter - def user(self, value: User): - self._user = value - - user: Mapped[User] = sa.orm.synonym( # type: ignore[no-redef] - '_user', descriptor=user - ) + if self.login_session: + return self.login_session.account + return self.account def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.token = make_buid() - if self._user: + if self.effective_user: self.refresh_token = make_buid() self.secret = newsecret() def __repr__(self) -> str: """Represent :class:`AuthToken` as a string.""" - return f'' + return f'' @property - def effective_scope(self) -> List: + def effective_scope(self) -> list: """Return effective scope of this token, combining granted and client scopes.""" return sorted(set(self.scope) | set(self.auth_client.scope)) @@ -527,12 +500,12 @@ def effective_scope(self) -> List: def last_used(self) -> datetime: """Return last used timestamp for this auth token.""" return ( - db.session.query(sa.func.max(auth_client_user_session.c.accessed_at)) - .select_from(auth_client_user_session, UserSession) + db.session.query(sa.func.max(auth_client_login_session.c.accessed_at)) + .select_from(auth_client_login_session, LoginSession) .filter( - auth_client_user_session.c.user_session_id == UserSession.id, - auth_client_user_session.c.auth_client_id == self.auth_client_id, - UserSession.user == self.user, + auth_client_login_session.c.login_session_id == LoginSession.id, + auth_client_login_session.c.auth_client_id == self.auth_client_id, + LoginSession.account == self.account, ) .scalar() ) @@ -543,25 +516,15 @@ def refresh(self) -> None: self.token = make_buid() self.secret = newsecret() - @property - def algorithm(self): - """Return algorithm used for mac token secrets (non-bearer tokens).""" - return self._algorithm - - @algorithm.setter - def algorithm(self, value: Optional[str]): + @sa.orm.validates('algorithm') + def _validate_algorithm(self, _key: str, value: str | None) -> str | None: """Set mac token algorithm to one of supported values.""" if value is None: - self._algorithm = None self.secret = None - elif value in ['hmac-sha-1', 'hmac-sha-256']: - self._algorithm = value - else: + return value + if value not in ['hmac-sha-1', 'hmac-sha-256']: raise ValueError(_("Unrecognized algorithm ‘{value}’").format(value=value)) - - algorithm: Mapped[str] = sa.orm.synonym( # type: ignore[no-redef] - '_algorithm', descriptor=algorithm - ) + return value def is_valid(self) -> bool: """Test if auth token is currently valid.""" @@ -573,51 +536,46 @@ def is_valid(self) -> bool: return True @classmethod - def migrate_user( # type: ignore[return] - cls, old_user: User, new_user: User - ) -> OptionalMigratedTables: - """Migrate one user account to another when merging user accounts.""" - oldtokens = cls.query.filter_by(user=old_user).all() - newtokens: Dict[int, List[AuthToken]] = {} # AuthClient: token mapping - for token in cls.query.filter_by(user=new_user).all(): + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" + oldtokens = cls.query.filter(cls.account == old_account).all() + newtokens: dict[int, list[AuthToken]] = {} # AuthClient: token mapping + for token in cls.query.filter(cls.account == new_account).all(): newtokens.setdefault(token.auth_client_id, []).append(token) for token in oldtokens: merge_performed = False if token.auth_client_id in newtokens: for newtoken in newtokens[token.auth_client_id]: - if newtoken.user == new_user: - # There's another token for newuser with the same client. + if newtoken.account == new_account: + # There's another token for new_account with the same client. # Just extend the scope there newtoken.scope = set(newtoken.scope) | set(token.scope) db.session.delete(token) merge_performed = True break if merge_performed is False: - token.user = new_user # Reassign this token to newuser + token.account = new_account # Reassign this token to new_account @classmethod - def get(cls, token: str) -> Optional[AuthToken]: + def get(cls, token: str) -> AuthToken | None: """ Return an AuthToken with the matching token. :param str token: Token to lookup """ - query = cls.query.filter_by(token=token).options( - sa.orm.joinedload(cls.auth_client).load_only('id', '_scope') - ) - return query.one_or_none() + return cls.query.filter(cls.token == token).join(AuthClient).one_or_none() @overload @classmethod - def get_for(cls, auth_client: AuthClient, *, user: User) -> Optional[AuthToken]: + def get_for(cls, auth_client: AuthClient, *, account: Account) -> AuthToken | None: ... @overload @classmethod def get_for( - cls, auth_client: AuthClient, *, user_session: UserSession - ) -> Optional[AuthToken]: + cls, auth_client: AuthClient, *, login_session: LoginSession + ) -> AuthToken | None: ... @classmethod @@ -625,98 +583,99 @@ def get_for( cls, auth_client: AuthClient, *, - user: Optional[User] = None, - user_session: Optional[UserSession] = None, - ) -> Optional[AuthToken]: - """Get an auth token for an auth client and a user or user session.""" - require_one_of(user=user, user_session=user_session) - if user is not None: - return cls.query.filter_by(auth_client=auth_client, user=user).one_or_none() - return cls.query.filter_by( - auth_client=auth_client, user_session=user_session + account: Account | None = None, + login_session: LoginSession | None = None, + ) -> AuthToken | None: + """Get an auth token for an auth client and an account or login session.""" + require_one_of(account=account, login_session=login_session) + if account is not None: + return cls.query.filter( + cls.auth_client == auth_client, cls.account == account + ).one_or_none() + return cls.query.filter( + cls.auth_client == auth_client, cls.login_session == login_session ).one_or_none() @classmethod - def all( # noqa: A003 - cls, users: Union[QueryBaseClass, Sequence[User]] - ) -> List[AuthToken]: - """Return all AuthToken for the specified users.""" - query = cls.query.options( - sa.orm.joinedload(cls.auth_client).load_only('id', '_scope') - ) - if isinstance(users, QueryBaseClass): - count = users.count() + def all(cls, accounts: Query | Sequence[Account]) -> list[AuthToken]: # noqa: A003 + """Return all AuthToken for the specified accounts.""" + query = cls.query.join(AuthClient) + if isinstance(accounts, QueryBaseClass): + count = accounts.count() if count == 1: - return query.filter_by(user=users.first()).all() + return query.filter(AuthToken.account == accounts.first()).all() if count > 1: return query.filter( - AuthToken.user_id.in_(users.options(load_only('id'))) + AuthToken.account_id.in_(accounts.options(load_only(Account.id))) ).all() else: - count = len(users) + count = len(accounts) if count == 1: # Cast users into a list/tuple before accessing [0], as the source # may not be an actual list with indexed access. For example, # Organization.owner_users is a DynamicAssociationProxy. - return query.filter_by(user=tuple(users)[0]).all() + return query.filter(AuthToken.account == tuple(accounts)[0]).all() if count > 1: - return query.filter(AuthToken.user_id.in_([u.id for u in users])).all() + return query.filter( + AuthToken.account_id.in_([u.id for u in accounts]) + ).all() return [] @classmethod - def all_for(cls, user: User) -> QueryBaseClass: - """Get all AuthTokens for a specified user (direct only).""" - return cls.query.filter_by(user=user) + def all_for(cls, account: Account) -> Query[AuthToken]: + """Get all AuthTokens for a specified account (direct only).""" + return cls.query.filter(cls.account == account) # This model's name is in plural because it defines multiple permissions within each # instance -class AuthClientUserPermissions(BaseMixin, db.Model): # type: ignore[name-defined] - """Permissions assigned to a user on a client app.""" +class AuthClientPermissions(BaseMixin, Model): + """Permissions assigned to an account on a client app.""" - __tablename__ = 'auth_client_user_permissions' - #: User who has these permissions - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user = sa.orm.relationship( - User, - primaryjoin=user_id == User.id, - backref=sa.orm.backref('client_permissions', cascade='all'), + __tablename__ = 'auth_client_permissions' + __tablename__ = 'auth_client_permissions' + #: User account that has these permissions + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship( + Account, + foreign_keys=[account_id], + backref=backref('client_permissions', cascade='all'), ) #: AuthClient app they are assigned on - auth_client_id = sa.Column( + auth_client_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True ) - auth_client: AuthClient = with_roles( - sa.orm.relationship( + auth_client: Mapped[AuthClient] = with_roles( + relationship( AuthClient, - primaryjoin=auth_client_id == AuthClient.id, - backref=sa.orm.backref('user_permissions', cascade='all'), + foreign_keys=[auth_client_id], + backref=backref('account_permissions', cascade='all'), ), grants_via={None: {'owner'}}, ) #: The permissions as a string of tokens - access_permissions = sa.Column( + access_permissions = sa.orm.mapped_column( 'permissions', sa.UnicodeText, default='', nullable=False ) - # Only one assignment per user and client - __table_args__ = (sa.UniqueConstraint('user_id', 'auth_client_id'),) + # Only one assignment per account and client + __table_args__ = (sa.UniqueConstraint('account_id', 'auth_client_id'),) # Used by auth_client_info.html @property def pickername(self) -> str: - """Return label string for identification of the subject user.""" - return self.user.pickername + """Return label string for identification of the subject account.""" + return self.account.pickername @classmethod - def migrate_user( # type: ignore[return] - cls, old_user: User, new_user: User - ) -> OptionalMigratedTables: - """Migrate one user account to another when merging user accounts.""" - for operm in old_user.client_permissions: + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" + for operm in old_account.client_permissions: merge_performed = False - for nperm in new_user.client_permissions: + for nperm in new_account.client_permissions: if nperm.auth_client == operm.auth_client: # Merge permission strings tokens = set(operm.access_permissions.split(' ')) @@ -727,53 +686,55 @@ def migrate_user( # type: ignore[return] db.session.delete(operm) merge_performed = True if not merge_performed: - operm.user = new_user + operm.account = new_account @classmethod def get( - cls, auth_client: AuthClient, user: User - ) -> Optional[AuthClientUserPermissions]: - """Get permissions for the specified auth client and user.""" - return cls.query.filter_by(auth_client=auth_client, user=user).one_or_none() + cls, auth_client: AuthClient, account: Account + ) -> AuthClientPermissions | None: + """Get permissions for the specified auth client and account.""" + return cls.query.filter( + cls.auth_client == auth_client, cls.account == account + ).one_or_none() @classmethod - def all_for(cls, user: User) -> QueryBaseClass: - """Get all permissions assigned to user for various clients.""" - return cls.query.filter_by(user=user) + def all_for(cls, account: Account) -> Query[AuthClientPermissions]: + """Get all permissions assigned to account for various clients.""" + return cls.query.filter(cls.account == account) @classmethod - def all_forclient(cls, auth_client: AuthClient) -> QueryBaseClass: + def all_forclient(cls, auth_client: AuthClient) -> Query[AuthClientPermissions]: """Get all permissions assigned on the specified auth client.""" - return cls.query.filter_by(auth_client=auth_client) + return cls.query.filter(cls.auth_client == auth_client) # This model's name is in plural because it defines multiple permissions within each # instance -class AuthClientTeamPermissions(BaseMixin, db.Model): # type: ignore[name-defined] +class AuthClientTeamPermissions(BaseMixin, Model): """Permissions assigned to a team on a client app.""" __tablename__ = 'auth_client_team_permissions' #: Team which has these permissions - team_id = sa.Column(sa.Integer, sa.ForeignKey('team.id'), nullable=False) - team = sa.orm.relationship( + team_id = sa.orm.mapped_column(sa.Integer, sa.ForeignKey('team.id'), nullable=False) + team = relationship( Team, - primaryjoin=team_id == Team.id, - backref=sa.orm.backref('client_permissions', cascade='all'), + foreign_keys=[team_id], + backref=backref('client_permissions', cascade='all'), ) #: AuthClient app they are assigned on - auth_client_id = sa.Column( + auth_client_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True ) - auth_client: AuthClient = with_roles( - sa.orm.relationship( + auth_client: Mapped[AuthClient] = with_roles( + relationship( AuthClient, - primaryjoin=auth_client_id == AuthClient.id, - backref=sa.orm.backref('team_permissions', cascade='all'), + foreign_keys=[auth_client_id], + backref=backref('team_permissions', cascade='all'), ), grants_via={None: {'owner'}}, ) #: The permissions as a string of tokens - access_permissions = sa.Column( + access_permissions = sa.orm.mapped_column( 'permissions', sa.UnicodeText, default='', nullable=False ) @@ -789,31 +750,36 @@ def pickername(self) -> str: @classmethod def get( cls, auth_client: AuthClient, team: Team - ) -> Optional[AuthClientTeamPermissions]: + ) -> AuthClientTeamPermissions | None: """Get permissions for the specified auth client and team.""" - return cls.query.filter_by(auth_client=auth_client, team=team).one_or_none() + return cls.query.filter( + cls.auth_client == auth_client, cls.team == team + ).one_or_none() @classmethod - def all_for(cls, auth_client: AuthClient, user: User) -> QueryBaseClass: - """Get all permissions for the specified user via their teams.""" - return cls.query.filter_by(auth_client=auth_client).filter( - cls.team_id.in_([team.id for team in user.teams]) + def all_for( + cls, auth_client: AuthClient, account: Account + ) -> Query[AuthClientPermissions]: + """Get all permissions for the specified account via their teams.""" + return cls.query.filter( + cls.auth_client == auth_client, + cls.team_id.in_([team.id for team in account.member_teams]), ) @classmethod - def all_forclient(cls, auth_client: AuthClient) -> QueryBaseClass: + def all_forclient(cls, auth_client: AuthClient) -> Query[AuthClientTeamPermissions]: """Get all permissions assigned on the specified auth client.""" - return cls.query.filter_by(auth_client=auth_client) + return cls.query.filter(cls.auth_client == auth_client) -@reopen(User) -class __User: +@reopen(Account) +class __Account: def revoke_all_auth_tokens(self) -> None: - """Revoke all auth tokens directly linked to the user.""" - AuthToken.all_for(cast(User, self)).delete(synchronize_session=False) + """Revoke all auth tokens directly linked to the account.""" + AuthToken.all_for(cast(Account, self)).delete(synchronize_session=False) def revoke_all_auth_client_permissions(self) -> None: - """Revoke all permissions on client apps assigned to user.""" - AuthClientUserPermissions.all_for(cast(User, self)).delete( + """Revoke all permissions on client apps assigned to account.""" + AuthClientPermissions.all_for(cast(Account, self)).delete( synchronize_session=False ) diff --git a/funnel/models/comment.py b/funnel/models/comment.py index 595a1f736..b10eb07c9 100644 --- a/funnel/models/comment.py +++ b/funnel/models/comment.py @@ -2,10 +2,9 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime -from typing import Iterable, List, Optional, Set, Union - -from sqlalchemy.orm import CompositeProperty +from typing import Any from werkzeug.utils import cached_property @@ -15,16 +14,30 @@ from . import ( BaseMixin, + DynamicMapped, Mapped, - MarkdownCompositeBasic, + Model, TSVectorType, UuidMixin, + backref, db, hybrid_property, + relationship, sa, ) -from .helpers import MessageComposite, add_search_trigger, reopen -from .user import DuckTypeUser, User, deleted_user, removed_user +from .account import ( + Account, + DuckTypeAccount, + deleted_account, + removed_account, + unknown_account, +) +from .helpers import ( + MarkdownCompositeBasic, + MessageComposite, + add_search_trigger, + reopen, +) __all__ = ['Comment', 'Commentset'] @@ -73,10 +86,10 @@ class SET_TYPE: # noqa: N801 # --- Models --------------------------------------------------------------------------- -class Commentset(UuidMixin, BaseMixin, db.Model): # type: ignore[name-defined] +class Commentset(UuidMixin, BaseMixin, Model): __tablename__ = 'commentset' #: Commentset state code - _state = sa.Column( + _state = sa.orm.mapped_column( 'state', sa.SmallInteger, StateManager.check_constraint('state', COMMENTSET_STATE), @@ -86,18 +99,20 @@ class Commentset(UuidMixin, BaseMixin, db.Model): # type: ignore[name-defined] #: Commentset state manager state = StateManager('_state', COMMENTSET_STATE, doc="Commentset state") #: Type of parent object - settype: sa.Column[Optional[int]] = with_roles( - sa.Column('type', sa.Integer, nullable=True), read={'all'}, datasets={'primary'} + settype: Mapped[int | None] = with_roles( + sa.orm.mapped_column('type', sa.Integer, nullable=True), + read={'all'}, + datasets={'primary'}, ) #: Count of comments, stored to avoid count(*) queries count = with_roles( - sa.Column(sa.Integer, default=0, nullable=False), + sa.orm.mapped_column(sa.Integer, default=0, nullable=False), read={'all'}, datasets={'primary'}, ) #: Timestamp of last comment, for ordering. - last_comment_at: sa.Column[Optional[datetime]] = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True), + last_comment_at: Mapped[datetime | None] = with_roles( + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'}, datasets={'primary'}, ) @@ -132,7 +147,7 @@ def parent(self) -> BaseMixin: with_roles(parent, read={'all'}, datasets={'primary'}) @cached_property - def parent_type(self) -> Optional[str]: + def parent_type(self) -> str | None: parent = self.parent if parent is not None: return parent.__tablename__ @@ -151,7 +166,7 @@ def last_comment(self): with_roles(last_comment, read={'all'}, datasets={'primary'}) def roles_for( - self, actor: Optional[User] = None, anchors: Iterable = () + self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: roles = super().roles_for(actor, anchors) parent_roles = self.parent.roles_for(actor, anchors) @@ -162,7 +177,7 @@ def roles_for( @with_roles(call={'all'}) @state.requires(state.NOT_DISABLED) def post_comment( - self, actor: User, message: str, in_reply_to: Optional[Comment] = None + self, actor: Account, message: str, in_reply_to: Comment | None = None ) -> Comment: """Post a comment.""" # TODO: Add role check for non-OPEN states. Either: @@ -170,7 +185,7 @@ def post_comment( # 2. Make a CommentMixin (like EmailAddressMixin) and insert logic into the # parent, which can override methods and add custom restrictions comment = Comment( - user=actor, + posted_by=actor, commentset=self, message=message, in_reply_to=in_reply_to, @@ -190,35 +205,41 @@ def enable_comments(self): # Transitions for the other two states are pending on the TODO notes in post_comment -class Comment(UuidMixin, BaseMixin, db.Model): # type: ignore[name-defined] +class Comment(UuidMixin, BaseMixin, Model): __tablename__ = 'comment' - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) - _user: Mapped[Optional[User]] = with_roles( - sa.orm.relationship( - User, backref=sa.orm.backref('comments', lazy='dynamic', cascade='all') + posted_by_id: Mapped[int | None] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=True + ) + _posted_by: Mapped[Account | None] = with_roles( + relationship( + Account, backref=backref('comments', lazy='dynamic', cascade='all') ), grants={'author'}, ) - commentset_id = sa.Column( + commentset_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id'), nullable=False ) - commentset = with_roles( - sa.orm.relationship( + commentset: Mapped[Commentset] = with_roles( + relationship( Commentset, - backref=sa.orm.backref('comments', lazy='dynamic', cascade='all'), + backref=backref('comments', lazy='dynamic', cascade='all'), ), grants_via={None: {'document_subscriber'}}, ) - in_reply_to_id = sa.Column(sa.Integer, sa.ForeignKey('comment.id'), nullable=True) - replies = sa.orm.relationship( - 'Comment', backref=sa.orm.backref('in_reply_to', remote_side='Comment.id') + in_reply_to_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('comment.id'), nullable=True + ) + replies: Mapped[list[Comment]] = relationship( + 'Comment', backref=backref('in_reply_to', remote_side='Comment.id') ) - _message = MarkdownCompositeBasic.create('message', nullable=False) + _message, message_text, message_html = MarkdownCompositeBasic.create( + 'message', nullable=False + ) - _state = sa.Column( + _state = sa.orm.mapped_column( 'state', sa.Integer, StateManager.check_constraint('state', COMMENT_STATE), @@ -228,24 +249,25 @@ class Comment(UuidMixin, BaseMixin, db.Model): # type: ignore[name-defined] state = StateManager('_state', COMMENT_STATE, doc="Current state of the comment") edited_at = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True), + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'}, datasets={'primary', 'related', 'json'}, ) #: Revision number maintained by SQLAlchemy, starting at 1 - revisionid = with_roles(sa.Column(sa.Integer, nullable=False), read={'all'}) - - search_vector = sa.orm.deferred( - sa.Column( - TSVectorType( - 'message_text', - weights={'message_text': 'A'}, - regconfig='english', - hltext=lambda: Comment.message_html, - ), - nullable=False, - ) + revisionid = with_roles( + sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + ) + + search_vector: Mapped[TSVectorType] = sa.orm.mapped_column( + TSVectorType( + 'message_text', + weights={'message_text': 'A'}, + regconfig='english', + hltext=lambda: Comment.message_html, + ), + nullable=False, + deferred=True, ) __table_args__ = ( @@ -259,7 +281,7 @@ class Comment(UuidMixin, BaseMixin, db.Model): # type: ignore[name-defined] 'read': {'created_at', 'urls', 'uuid_b58', 'has_replies'}, 'call': {'state', 'commentset', 'view_for', 'url_for'}, }, - 'replied_to_commenter': {'granted_via': {'in_reply_to': '_user'}}, + 'replied_to_commenter': {'granted_via': {'in_reply_to': '_posted_by'}}, } __datasets__ = { @@ -274,11 +296,11 @@ def __init__(self, **kwargs) -> None: self.commentset.last_comment_at = sa.func.utcnow() @cached_property - def has_replies(self): + def has_replies(self) -> bool: return bool(self.replies) @property - def current_access_replies(self) -> List[RoleAccessProxy]: + def current_access_replies(self) -> list[RoleAccessProxy]: return [ reply.current_access(datasets=('json', 'related')) for reply in self.replies @@ -288,29 +310,33 @@ def current_access_replies(self) -> List[RoleAccessProxy]: with_roles(current_access_replies, read={'all'}, datasets={'related', 'json'}) @hybrid_property - def user(self) -> Union[User, DuckTypeUser]: + def posted_by(self) -> Account | DuckTypeAccount: return ( - deleted_user + deleted_account if self.state.DELETED - else removed_user + else removed_account if self.state.SPAM - else self._user + else unknown_account + if self._posted_by is None + else self._posted_by ) - @user.setter - def user(self, value: Optional[User]) -> None: - self._user = value + @posted_by.inplace.setter # type: ignore[arg-type] + def _posted_by_setter(self, value: Account | None) -> None: + self._posted_by = value - @user.expression - def user(cls): # noqa: N805 # pylint: disable=no-self-argument - return cls._user + @posted_by.inplace.expression + @classmethod + def _posted_by_expression(cls) -> sa.orm.InstrumentedAttribute[Account | None]: + """Return SQL Expression.""" + return cls._posted_by - with_roles(user, read={'all'}, datasets={'primary', 'related', 'json', 'minimal'}) + with_roles( + posted_by, read={'all'}, datasets={'primary', 'related', 'json', 'minimal'} + ) - # XXX: We're returning MarkownComposite, not CompositeProperty, but mypy doesn't - # know. This is pending a fix to SQLAlchemy's type system, hopefully in 2.0 @hybrid_property - def message(self) -> Union[CompositeProperty, MessageComposite]: + def message(self) -> MessageComposite | MarkdownCompositeBasic: """Return the message of the comment if not deleted or removed.""" return ( message_deleted @@ -320,13 +346,14 @@ def message(self) -> Union[CompositeProperty, MessageComposite]: else self._message ) - @message.setter - def message(self, value: str) -> None: + @message.inplace.setter + def _message_setter(self, value: Any) -> None: """Edit the message of a comment.""" self._message = value # type: ignore[assignment] - @message.expression - def message(cls): # noqa: N805 # pylint: disable=no-self-argument + @message.inplace.expression + @classmethod + def _message_expression(cls): """Return SQL expression for comment message column.""" return cls._message @@ -345,21 +372,21 @@ def title(self) -> str: obj = self.commentset.parent if obj is not None: return _("{user} commented on {obj}").format( - user=self.user.pickername, obj=obj.title + user=self.posted_by.pickername, obj=obj.title ) - return _("{user} commented").format(user=self.user.pickername) + return _("{account} commented").format(account=self.posted_by.pickername) with_roles(title, read={'all'}, datasets={'primary', 'related', 'json'}) @property - def badges(self) -> Set[str]: + def badges(self) -> set[str]: badges = set() roles = set() if self.commentset.project is not None: - roles = self.commentset.project.roles_for(self._user) + roles = self.commentset.project.roles_for(self._posted_by) elif self.commentset.proposal is not None: - roles = self.commentset.proposal.project.roles_for(self._user) - if 'submitter' in self.commentset.proposal.roles_for(self._user): + roles = self.commentset.proposal.project.roles_for(self._posted_by) + if 'submitter' in self.commentset.proposal.roles_for(self._posted_by): badges.add(_("Submitter")) if 'editor' in roles: if 'promoter' in roles: @@ -376,8 +403,8 @@ def badges(self) -> Set[str]: def delete(self) -> None: """Delete this comment.""" if len(self.replies) > 0: - self.user = None # type: ignore[assignment] - self.message = '' # type: ignore[assignment] + self.posted_by = None + self.message = '' else: if self.in_reply_to and self.in_reply_to.state.DELETED: # If the comment this is replying to is deleted, ask it to reconsider @@ -398,7 +425,7 @@ def mark_not_spam(self) -> None: """Mark this comment as not spam.""" def roles_for( - self, actor: Optional[User] = None, anchors: Iterable = () + self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: roles = super().roles_for(actor, anchors) roles.add('reader') @@ -410,7 +437,7 @@ def roles_for( @reopen(Commentset) class __Commentset: - toplevel_comments = sa.orm.relationship( + toplevel_comments: DynamicMapped[Comment] = relationship( Comment, lazy='dynamic', primaryjoin=sa.and_( diff --git a/funnel/models/commentset_membership.py b/funnel/models/commentset_membership.py index fafbb00e3..4becbd934 100644 --- a/funnel/models/commentset_membership.py +++ b/funnel/models/commentset_membership.py @@ -2,13 +2,12 @@ from __future__ import annotations -from typing import Set - from werkzeug.utils import cached_property -from coaster.sqlalchemy import DynamicAssociationProxy, Query, immutable, with_roles +from coaster.sqlalchemy import DynamicAssociationProxy, with_roles -from . import User, db, sa +from . import DynamicMapped, Mapped, Model, Query, backref, db, relationship, sa +from .account import Account from .comment import Comment, Commentset from .helpers import reopen from .membership_mixin import ImmutableUserMembershipMixin @@ -19,10 +18,7 @@ __all__ = ['CommentsetMembership'] -class CommentsetMembership( - ImmutableUserMembershipMixin, - db.Model, # type: ignore[name-defined] -): +class CommentsetMembership(ImmutableUserMembershipMixin, Model): """Membership roles for users who are commentset users and subscribers.""" __tablename__ = 'commentset_membership' @@ -30,10 +26,10 @@ class CommentsetMembership( __data_columns__ = ('last_seen_at', 'is_muted') __roles__ = { - 'subject': { + 'member': { 'read': { 'urls', - 'user', + 'member', 'commentset', 'is_muted', 'last_seen_at', @@ -42,46 +38,43 @@ class CommentsetMembership( } } - commentset_id: sa.Column[sa.Integer] = immutable( - sa.Column( - sa.Integer, - sa.ForeignKey('commentset.id', ondelete='CASCADE'), - nullable=False, - ) + commentset_id: Mapped[int] = sa.orm.mapped_column( + sa.Integer, + sa.ForeignKey('commentset.id', ondelete='CASCADE'), + nullable=False, ) - commentset: sa.orm.relationship[Commentset] = immutable( - sa.orm.relationship( - Commentset, - backref=sa.orm.backref( - 'subscriber_memberships', - lazy='dynamic', - cascade='all', - passive_deletes=True, - ), - ) + commentset: Mapped[Commentset] = relationship( + Commentset, + backref=backref( + 'subscriber_memberships', + lazy='dynamic', + cascade='all', + passive_deletes=True, + ), ) - parent = sa.orm.synonym('commentset') - parent_id = sa.orm.synonym('commentset_id') + parent_id: Mapped[int] = sa.orm.synonym('commentset_id') + parent_id_column = 'commentset_id' + parent: Mapped[Commentset] = sa.orm.synonym('commentset') #: Flag to indicate notifications are muted - is_muted = sa.Column(sa.Boolean, nullable=False, default=False) + is_muted = sa.orm.mapped_column(sa.Boolean, nullable=False, default=False) #: When the user visited this commentset last - last_seen_at = sa.Column( + last_seen_at = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) - new_comment_count = sa.orm.column_property( - sa.select(sa.func.count(Comment.id)) # type: ignore[attr-defined] + new_comment_count: Mapped[int] = sa.orm.column_property( + sa.select(sa.func.count(Comment.id)) .where(Comment.commentset_id == commentset_id) # type: ignore[has-type] .where(Comment.state.PUBLIC) # type: ignore[has-type] .where(Comment.created_at > last_seen_at) - .correlate_except(Comment) # type: ignore[arg-type] - .scalar_subquery() # sqlalchemy-stubs doesn't know of this + .correlate_except(Comment) + .scalar_subquery() ) @cached_property - def offered_roles(self) -> Set[str]: + def offered_roles(self) -> set[str]: """ Roles offered by this membership record. @@ -90,23 +83,23 @@ def offered_roles(self) -> Set[str]: return {'document_subscriber'} def update_last_seen_at(self) -> None: - """Mark the subject user as having last seen this commentset just now.""" + """Mark the member as having seen this commentset just now.""" self.last_seen_at = sa.func.utcnow() @classmethod - def for_user(cls, user: User) -> Query: + def for_user(cls, account: Account) -> Query[CommentsetMembership]: """ Return a query representing all active commentset memberships for a user. This classmethod mirrors the functionality in - :attr:`User.active_commentset_memberships` with the difference that since it's - a query on the class, it returns an instance of the query subclass from + :attr:`Account.active_commentset_memberships` with the difference that since + it's a query on the class, it returns an instance of the query subclass from Flask-SQLAlchemy and Coaster. Relationships use the main class from SQLAlchemy which is missing pagination and the empty/notempty methods. """ return ( cls.query.filter( - cls.user == user, + cls.member == account, CommentsetMembership.is_active, ) .join(Commentset) @@ -121,14 +114,14 @@ def for_user(cls, user: User) -> Query: ) -@reopen(User) -class __User: - active_commentset_memberships = sa.orm.relationship( +@reopen(Account) +class __Account: + active_commentset_memberships: DynamicMapped[CommentsetMembership] = relationship( CommentsetMembership, lazy='dynamic', primaryjoin=sa.and_( - CommentsetMembership.user_id == User.id, # type: ignore[has-type] - CommentsetMembership.is_active, # type: ignore[arg-type] + CommentsetMembership.member_id == Account.id, + CommentsetMembership.is_active, ), viewonly=True, ) @@ -140,48 +133,48 @@ class __User: @reopen(Commentset) class __Commentset: - active_memberships = sa.orm.relationship( + active_memberships: DynamicMapped[CommentsetMembership] = relationship( CommentsetMembership, lazy='dynamic', primaryjoin=sa.and_( CommentsetMembership.commentset_id == Commentset.id, - CommentsetMembership.is_active, # type: ignore[arg-type] + CommentsetMembership.is_active, ), viewonly=True, ) # Send notifications only to subscribers who haven't muted - active_memberships_unmuted = with_roles( - sa.orm.relationship( + active_memberships_unmuted: DynamicMapped[CommentsetMembership] = with_roles( + relationship( CommentsetMembership, lazy='dynamic', primaryjoin=sa.and_( CommentsetMembership.commentset_id == Commentset.id, - CommentsetMembership.is_active, # type: ignore[arg-type] + CommentsetMembership.is_active, CommentsetMembership.is_muted.is_(False), ), viewonly=True, ), - grants_via={'user': {'document_subscriber'}}, + grants_via={'member': {'document_subscriber'}}, ) - def update_last_seen_at(self, user: User) -> None: + def update_last_seen_at(self, member: Account) -> None: subscription = CommentsetMembership.query.filter_by( - commentset=self, user=user, is_active=True + commentset=self, member=member, is_active=True ).one_or_none() if subscription is not None: subscription.update_last_seen_at() - def add_subscriber(self, actor: User, user: User) -> bool: + def add_subscriber(self, actor: Account, member: Account) -> bool: """Return True is subscriber is added or unmuted, False if already exists.""" changed = False subscription = CommentsetMembership.query.filter_by( - commentset=self, user=user, is_active=True + commentset=self, member=member, is_active=True ).one_or_none() if subscription is None: subscription = CommentsetMembership( commentset=self, - user=user, + member=member, granted_by=actor, ) db.session.add(subscription) @@ -192,30 +185,30 @@ def add_subscriber(self, actor: User, user: User) -> bool: subscription.update_last_seen_at() return changed - def mute_subscriber(self, actor: User, user: User) -> bool: + def mute_subscriber(self, actor: Account, member: Account) -> bool: """Return True if subscriber was muted, False if already muted or missing.""" subscription = CommentsetMembership.query.filter_by( - commentset=self, user=user, is_active=True + commentset=self, member=member, is_active=True ).one_or_none() if not subscription.is_muted: subscription.replace(actor=actor, is_muted=True) return True return False - def unmute_subscriber(self, actor: User, user: User) -> bool: + def unmute_subscriber(self, actor: Account, member: Account) -> bool: """Return True if subscriber was unmuted, False if not muted or missing.""" subscription = CommentsetMembership.query.filter_by( - commentset=self, user=user, is_active=True + commentset=self, member=member, is_active=True ).one_or_none() if subscription.is_muted: subscription.replace(actor=actor, is_muted=False) return True return False - def remove_subscriber(self, actor: User, user: User) -> bool: + def remove_subscriber(self, actor: Account, member: Account) -> bool: """Return True is subscriber is removed, False if already removed.""" subscription = CommentsetMembership.query.filter_by( - commentset=self, user=user, is_active=True + commentset=self, member=member, is_active=True ).one_or_none() if subscription is not None: subscription.revoke(actor=actor) diff --git a/funnel/models/contact_exchange.py b/funnel/models/contact_exchange.py index db771cc0a..c9e18974b 100644 --- a/funnel/models/contact_exchange.py +++ b/funnel/models/contact_exchange.py @@ -2,23 +2,32 @@ from __future__ import annotations +from collections.abc import Collection, Sequence from dataclasses import dataclass -from datetime import date as date_type -from datetime import datetime +from datetime import date as date_type, datetime from itertools import groupby -from typing import Collection, Iterable, Optional from uuid import UUID +from pytz import timezone from sqlalchemy.ext.associationproxy import association_proxy from coaster.sqlalchemy import LazyRoleSet from coaster.utils import uuid_to_base58 -from ..typing import OptionalMigratedTables -from . import RoleMixin, TimestampMixin, db, sa +from . import ( + Mapped, + Model, + Query, + RoleMixin, + TimestampMixin, + backref, + db, + relationship, + sa, +) +from .account import Account from .project import Project from .sync_ticket import TicketParticipant -from .user import User __all__ = ['ContactExchange'] @@ -44,21 +53,17 @@ class DateCountContacts: contacts: Collection[ContactExchange] -class ContactExchange( - TimestampMixin, - RoleMixin, - db.Model, # type: ignore[name-defined] -): +class ContactExchange(TimestampMixin, RoleMixin, Model): """Model to track who scanned whose badge, in which project.""" __tablename__ = 'contact_exchange' #: User who scanned this contact - user_id = sa.Column( - sa.Integer, sa.ForeignKey('user.id', ondelete='CASCADE'), primary_key=True + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id', ondelete='CASCADE'), primary_key=True ) - user = sa.orm.relationship( - User, - backref=sa.orm.backref( + account: Mapped[Account] = relationship( + Account, + backref=backref( 'scanned_contacts', lazy='dynamic', order_by='ContactExchange.scanned_at.desc()', @@ -66,29 +71,29 @@ class ContactExchange( ), ) #: Participant whose contact was scanned - ticket_participant_id = sa.Column( + ticket_participant_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_participant.id', ondelete='CASCADE'), primary_key=True, index=True, ) - ticket_participant = sa.orm.relationship( + ticket_participant: Mapped[TicketParticipant] = relationship( TicketParticipant, - backref=sa.orm.backref('scanned_contacts', passive_deletes=True), + backref=backref('scanned_contacts', passive_deletes=True), ) #: Datetime at which the scan happened - scanned_at = sa.Column( + scanned_at = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) #: Note recorded by the user (plain text) - description = sa.Column(sa.UnicodeText, nullable=False, default='') + description = sa.orm.mapped_column(sa.UnicodeText, nullable=False, default='') #: Archived flag - archived = sa.Column(sa.Boolean, nullable=False, default=False) + archived = sa.orm.mapped_column(sa.Boolean, nullable=False, default=False) __roles__ = { 'owner': { 'read': { - 'user', + 'account', 'ticket_participant', 'scanned_at', 'description', @@ -96,110 +101,115 @@ class ContactExchange( }, 'write': {'description', 'archived'}, }, - 'subject': {'read': {'user', 'ticket_participant', 'scanned_at'}}, + 'subject': {'read': {'account', 'ticket_participant', 'scanned_at'}}, } def roles_for( - self, actor: Optional[User] = None, anchors: Iterable = () + self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: roles = super().roles_for(actor, anchors) if actor is not None: - if actor == self.user: + if actor == self.account: roles.add('owner') - if actor == self.ticket_participant.user: + if actor == self.ticket_participant.participant: roles.add('subject') return roles @classmethod - def migrate_user( # type: ignore[return] - cls, old_user: User, new_user: User - ) -> OptionalMigratedTables: - """Migrate one user account to another when merging user accounts.""" + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" ticket_participant_ids = { - ce.ticket_participant_id for ce in new_user.scanned_contacts + ce.ticket_participant_id for ce in new_account.scanned_contacts } - for ce in old_user.scanned_contacts: + for ce in old_account.scanned_contacts: if ce.ticket_participant_id not in ticket_participant_ids: - ce.user = new_user + ce.account = new_account else: # Discard duplicate contact exchange db.session.delete(ce) @classmethod - def grouped_counts_for(cls, user, archived=False): + def grouped_counts_for( + cls, account: Account, archived: bool = False + ) -> list[tuple[ProjectId, list[DateCountContacts]]]: """Return count of contacts grouped by project and date.""" - query = db.session.query( - cls.scanned_at, Project.id, Project.uuid, Project.timezone, Project.title + subq = sa.select( + cls.scanned_at.label('scanned_at'), + Project.id.label('project_id'), + Project.uuid.label('project_uuid'), + Project.timezone.label('project_timezone'), + Project.title.label('project_title'), ).filter( cls.ticket_participant_id == TicketParticipant.id, TicketParticipant.project_id == Project.id, - cls.user == user, + cls.account == account, ) if not archived: # If archived: return everything (contacts including archived contacts) # If not archived: return only unarchived contacts - query = query.filter(cls.archived.is_(False)) + subq = subq.filter(cls.archived.is_(False)) - # from_self turns `SELECT columns` into `SELECT new_columns FROM (SELECT - # columns)` query = ( - query.from_self( - Project.id.label('id'), - Project.uuid.label('uuid'), - Project.title.label('title'), - Project.timezone.label('timezone'), + db.session.query( + sa.column('project_id'), + sa.column('project_uuid'), + sa.column('project_title'), + sa.column('project_timezone'), sa.cast( sa.func.date_trunc( - 'day', sa.func.timezone(Project.timezone, cls.scanned_at) + 'day', + sa.func.timezone( + sa.column('project_timezone'), sa.column('scanned_at') + ), ), sa.Date, - ).label('date'), + ).label('scan_date'), sa.func.count().label('count'), ) + .select_from(subq.subquery()) .group_by( - sa.text('id'), - sa.text('uuid'), - sa.text('title'), - sa.text('timezone'), - sa.text('date'), + sa.column('project_id'), + sa.column('project_uuid'), + sa.column('project_title'), + sa.column('project_timezone'), + sa.column('scan_date'), ) - .order_by(sa.text('date DESC')) + .order_by(sa.text('scan_date DESC')) ) # Issued SQL: # # SELECT - # project_id AS id, - # project_uuid AS uuid, - # project_title AS title, - # project_timezone AS "timezone", - # date_trunc( - # 'day', - # timezone("timezone", contact_exchange_scanned_at) - # )::date AS date, + # project_id, + # project_uuid, + # project_title, + # project_timezone, + # CAST( + # date_trunc('day', timezone(project_timezone, scanned_at)) + # AS DATE + # ) AS scan_date, # count(*) AS count # FROM ( # SELECT - # contact_exchange.scanned_at AS contact_exchange_scanned_at, + # contact_exchange.scanned_at AS scanned_at, # project.id AS project_id, # project.uuid AS project_uuid, - # project.title AS project_title, - # project.timezone AS project_timezone - # FROM contact_exchange, ticket_participant, project + # project.timezone AS project_timezone, + # project.title AS project_title + # FROM contact_exchange, project, ticket_participant # WHERE # contact_exchange.ticket_participant_id = ticket_participant.id # AND ticket_participant.project_id = project.id - # AND contact_exchange.user_id = :user_id + # AND :account_id = contact_exchange.account_id + # AND contact_exchange.archived IS false # ) AS anon_1 - # GROUP BY id, uuid, title, timezone, date - # ORDER BY date DESC; + # GROUP BY project_id, project_uuid, project_title, project_timezone, scan_date + # ORDER BY scan_date DESC - # Our query result looks like this: - # [(id, uuid, title, timezone, date, count), ...] - # where (id, uuid, title, timezone) repeat for each date - # - # Transform it into this: + # The query result has rows of: + # (project_id, project_uuid, project_title, project_timezone, scan_date, count) + # with one row per date. It is then transformed into: # [ # (ProjectId(id, uuid, uuid_b58, title, timezone), [ # DateCountContacts(date, count, contacts), @@ -210,18 +220,18 @@ def grouped_counts_for(cls, user, archived=False): # ] # We don't do it here, but this can easily be converted into a dictionary of - # {project: dates}: - # >>> OrderedDict(result) # Preserve order with most recent projects first - # >>> dict(result) # Don't preserve order + # `{project: dates}` using `dict(result)` groups = [ ( k, [ DateCountContacts( - r.date, + r.scan_date, r.count, - cls.contacts_for_project_and_date(user, k, r.date, archived), + cls.contacts_for_project_and_date( + account, k, r.scan_date, archived + ), ) for r in g ], @@ -229,7 +239,11 @@ def grouped_counts_for(cls, user, archived=False): for k, g in groupby( query, lambda r: ProjectId( - r.id, r.uuid, uuid_to_base58(r.uuid), r.title, r.timezone + id=r.project_id, + uuid=r.project_uuid, + uuid_b58=uuid_to_base58(r.project_uuid), + title=r.project_title, + timezone=timezone(r.project_timezone), ), ) ] @@ -238,11 +252,11 @@ def grouped_counts_for(cls, user, archived=False): @classmethod def contacts_for_project_and_date( - cls, user: User, project: Project, date: date_type, archived=False - ): + cls, account: Account, project: Project, date: date_type, archived: bool = False + ) -> Query[ContactExchange]: """Return contacts for a given user, project and date.""" query = cls.query.join(TicketParticipant).filter( - cls.user == user, + cls.account == account, # For safety always use objects instead of column values. The following # expression should have been `Participant.project == project`. However, we # are using `id` here because `project` may be an instance of ProjectId @@ -264,10 +278,12 @@ def contacts_for_project_and_date( return query @classmethod - def contacts_for_project(cls, user, project, archived=False): + def contacts_for_project( + cls, account: Account, project: Project, archived: bool = False + ) -> Query[ContactExchange]: """Return contacts for a given user and project.""" query = cls.query.join(TicketParticipant).filter( - cls.user == user, + cls.account == account, # See explanation for the following expression in # `contacts_for_project_and_date` TicketParticipant.project_id == project.id, @@ -279,4 +295,4 @@ def contacts_for_project(cls, user, project, archived=False): return query -TicketParticipant.scanning_users = association_proxy('scanned_contacts', 'user') +TicketParticipant.scanning_users = association_proxy('scanned_contacts', 'account') diff --git a/funnel/models/draft.py b/funnel/models/draft.py index bc11297dc..d51f79b92 100644 --- a/funnel/models/draft.py +++ b/funnel/models/draft.py @@ -2,29 +2,31 @@ from __future__ import annotations +from uuid import UUID + from werkzeug.datastructures import MultiDict -from . import NoIdMixin, UUIDType, db, json_type, sa +from . import Mapped, Model, NoIdMixin, sa, types __all__ = ['Draft'] -class Draft(NoIdMixin, db.Model): # type: ignore[name-defined] +class Draft(NoIdMixin, Model): """Store for autosaved, unvalidated drafts on behalf of other models.""" __tablename__ = 'draft' - table = sa.Column(sa.UnicodeText, primary_key=True) - table_row_id = sa.Column(UUIDType(binary=False), primary_key=True) - body = sa.Column(json_type, nullable=False, server_default='{}') - revision = sa.Column(UUIDType(binary=False)) + table: Mapped[types.text] = sa.orm.mapped_column(primary_key=True) + table_row_id: Mapped[UUID] = sa.orm.mapped_column(primary_key=True) + body: Mapped[types.jsonb_dict | None] # Optional only when instance is new + revision: Mapped[UUID | None] @property - def formdata(self): - return MultiDict(self.body.get('form', {})) + def formdata(self) -> MultiDict: + return MultiDict(self.body.get('form', {}) if self.body is not None else {}) @formdata.setter - def formdata(self, value): + def formdata(self, value: MultiDict | dict) -> None: if self.body is not None: self.body['form'] = value else: diff --git a/funnel/models/email_address.py b/funnel/models/email_address.py index 51396cba8..986f41be2 100644 --- a/funnel/models/email_address.py +++ b/funnel/models/email_address.py @@ -2,35 +2,35 @@ from __future__ import annotations -from typing import Any, List, Optional, Set, Union, cast, overload import hashlib import unicodedata +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload +import base58 +import idna +from pyisemail import is_email +from pyisemail.diagnosis import BaseDiagnosis from sqlalchemy import event, inspect -from sqlalchemy.orm import mapper +from sqlalchemy.orm import Mapper from sqlalchemy.orm.attributes import NO_VALUE -from sqlalchemy.sql.expression import ColumnElement - from werkzeug.utils import cached_property -from pyisemail import is_email -from pyisemail.diagnosis import BaseDiagnosis -from typing_extensions import Literal -import base58 -import idna - -from coaster.sqlalchemy import ( - Query, - StateManager, - auto_init_default, - immutable, - with_roles, -) +from coaster.sqlalchemy import StateManager, auto_init_default, immutable, with_roles from coaster.utils import LabeledEnum, require_one_of from ..signals import emailaddress_refcount_dropping -from ..typing import Mapped -from . import BaseMixin, db, declarative_mixin, declared_attr, hybrid_property, sa +from . import ( + BaseMixin, + Mapped, + Model, + Query, + db, + declarative_mixin, + declared_attr, + hybrid_property, + relationship, + sa, +) __all__ = [ 'EMAIL_DELIVERY_STATE', @@ -63,7 +63,7 @@ class EMAIL_DELIVERY_STATE(LabeledEnum): # noqa: N801 HARD_FAIL = (5, 'hard_fail') # Hard fail reported -def canonical_email_representation(email: str) -> List[str]: +def canonical_email_representation(email: str) -> list[str]: """ Construct canonical representations of the email address, for deduplication. @@ -148,16 +148,17 @@ class EmailAddressInUseError(EmailAddressError): """Email address is in use by another owner.""" -class EmailAddress(BaseMixin, db.Model): # type: ignore[name-defined] +class EmailAddress(BaseMixin, Model): """ Represents an email address as a standalone entity, with associated metadata. Prior to this model, email addresses were regarded as properties of other models. - Specifically: Proposal.email, Participant.email, User.emails and User.emailclaims, - the latter two lists populated using the UserEmail and UserEmailClaim join models. - This subordination made it difficult to track ownership of an email address or its - reachability (active, bouncing, etc). Having EmailAddress as a standalone model - (with incoming foreign keys) provides some sanity: + Specifically: Proposal.email, Participant.email, Account.emails and + Account.emailclaims, the latter two lists populated using the AccountEmail and + AccountEmailClaim join models. This subordination made it difficult to track + ownership of an email address or its reachability (active, bouncing, etc). Having + EmailAddress as a standalone model (with incoming foreign keys) provides some + sanity: 1. Email addresses are stored with a hash, and always looked up using the hash. This allows the address to be forgotten while preserving the record for metadata. @@ -168,7 +169,7 @@ class EmailAddress(BaseMixin, db.Model): # type: ignore[name-defined] 4. If there is abuse, an email address can be comprehensively blocked using its canonical representation, which prevents the address from being used even via its ``+sub-address`` variations. - 5. Via :class:`EmailAddressMixin`, the UserEmail model can establish ownership of + 5. Via :class:`EmailAddressMixin`, the AccountEmail model can establish ownership of an email address on behalf of a user, placing an automatic block on its use by other users. This mechanism is not limited to users. A future OrgEmail link can establish ownership on behalf of an organization. @@ -182,17 +183,17 @@ class EmailAddress(BaseMixin, db.Model): # type: ignore[name-defined] #: Backrefs to this model from other models, populated by :class:`EmailAddressMixin` #: Contains the name of the relationship in the :class:`EmailAddress` model - __backrefs__: Set[str] = set() + __backrefs__: ClassVar[set[str]] = set() #: These backrefs claim exclusive use of the email address for their linked owner. #: See :class:`EmailAddressMixin` for implementation detail - __exclusive_backrefs__: Set[str] = set() + __exclusive_backrefs__: ClassVar[set[str]] = set() #: The email address, centrepiece of this model. Case preserving. #: Validated by the :func:`_validate_email` event handler - email = sa.Column(sa.Unicode, nullable=True) + email = sa.orm.mapped_column(sa.Unicode, nullable=True) #: The domain of the email, stored for quick lookup of related addresses #: Read-only, accessible via the :property:`domain` property - _domain = sa.Column('domain', sa.Unicode, nullable=True, index=True) + _domain = sa.orm.mapped_column('domain', sa.Unicode, nullable=True, index=True) # email_normalized is defined below @@ -200,10 +201,10 @@ class EmailAddress(BaseMixin, db.Model): # type: ignore[name-defined] #: email is removed. SQLAlchemy type LargeBinary maps to PostgreSQL BYTEA. Despite #: the name, we're only storing 20 bytes blake2b160 = immutable( - sa.Column( + sa.orm.mapped_column( sa.LargeBinary, sa.CheckConstraint( - sa.func.length(sa.sql.column('blake2b160')) == 20, + 'LENGTH(blake2b160) = 20', name='email_address_blake2b160_check', ), nullable=False, @@ -215,11 +216,11 @@ class EmailAddress(BaseMixin, db.Model): # type: ignore[name-defined] #: email detection. Indexed but does not use a unique constraint because a+b@tld and #: a+c@tld are both a@tld canonically but can exist in records separately. blake2b160_canonical = immutable( - sa.Column(sa.LargeBinary, nullable=False, index=True) + sa.orm.mapped_column(sa.LargeBinary, nullable=False, index=True) ) #: Does this email address work? Records last known delivery state - _delivery_state = sa.Column( + _delivery_state = sa.orm.mapped_column( 'delivery_state', sa.Integer, StateManager.check_constraint( @@ -236,18 +237,20 @@ class EmailAddress(BaseMixin, db.Model): # type: ignore[name-defined] doc="Last known delivery state of this email address", ) #: Timestamp of last known delivery state - delivery_state_at = sa.Column( + delivery_state_at = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) #: Timestamp of last known recipient activity resulting from sent mail - active_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + active_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) #: Is this email address blocked from being used? If so, :attr:`email` should be #: null. Blocks apply to the canonical address (without the +sub-address variation), #: so a test for whether an address is blocked should use blake2b160_canonical to #: load the record. Other records with the same canonical hash _may_ exist without #: setting the flag due to a lack of database-side enforcement - _is_blocked = sa.Column('is_blocked', sa.Boolean, nullable=False, default=False) + _is_blocked = sa.orm.mapped_column( + 'is_blocked', sa.Boolean, nullable=False, default=False + ) __table_args__ = ( # `domain` must be lowercase always. Note that Python `.lower()` is not @@ -258,8 +261,8 @@ class EmailAddress(BaseMixin, db.Model): # type: ignore[name-defined] ), # If `is_blocked` is True, `email` and `domain` must be None sa.CheckConstraint( - sa.or_( # type: ignore[arg-type] - _is_blocked.isnot(True), + sa.or_( + _is_blocked.is_not(True), sa.and_(_is_blocked.is_(True), email.is_(None), _domain.is_(None)), ), 'email_address_email_is_blocked_check', @@ -269,7 +272,7 @@ class EmailAddress(BaseMixin, db.Model): # type: ignore[name-defined] # easy way to do an IDN match in Postgres without an extension. # `_` and `%` must be escaped as they are wildcards to the LIKE/ILIKE operator sa.CheckConstraint( - sa.or_( # type: ignore[arg-type] + sa.or_( # email and domain must both be non-null, or sa.and_(email.is_(None), _domain.is_(None)), # domain must be an IDN, or @@ -296,19 +299,19 @@ def is_blocked(self) -> bool: return self._is_blocked @hybrid_property - def domain(self) -> Optional[str]: + def domain(self) -> str | None: """Domain of the email, stored for quick lookup of related addresses.""" return self._domain # This should not use `cached_property` as email is partially mutable @property - def email_normalized(self) -> Optional[str]: + def email_normalized(self) -> str | None: """Return normalized representation of the email address, for hashing.""" return email_normalized(self.email) if self.email else None # This should not use `cached_property` as email is partially mutable @property - def email_canonical(self) -> Optional[str]: + def email_canonical(self) -> str | None: """ Email address with the ``+sub-address`` portion of the mailbox removed. @@ -331,12 +334,11 @@ def email_hash(self) -> str: transport_hash = email_hash @with_roles(call={'all'}) - def md5(self) -> Optional[str]: + def md5(self) -> str | None: """MD5 hash of :property:`email_normalized`, for legacy use only.""" - # TODO: After upgrading to Python 3.9, use usedforsecurity=False return ( - hashlib.md5( # nosec # skipcq: PTC-W1003 - self.email_normalized.encode('utf-8') + hashlib.md5( + self.email_normalized.encode('utf-8'), usedforsecurity=False ).hexdigest() if self.email_normalized else None @@ -346,11 +348,18 @@ def __str__(self) -> str: """Cast email address into a string.""" return self.email or '' + def __format__(self, format_spec: str) -> str: + """Format the email address.""" + if not format_spec: + return self.__str__() + return self.__str__().__format__(format_spec) + def __repr__(self) -> str: """Debugging representation of the email address.""" return f'EmailAddress({self.email!r})' def __init__(self, email: str) -> None: + super().__init__() if not isinstance(email, str): raise ValueError("A string email address is required") # Set the hash first so the email column validator passes. Both hash columns @@ -372,8 +381,8 @@ def is_exclusive(self) -> bool: for related_obj in getattr(self, backref_name) ) - def is_available_for(self, owner: object) -> bool: - """Return True if this EmailAddress is available for the given owner.""" + def is_available_for(self, owner: Account | None) -> bool: + """Return True if this EmailAddress is available for the proposed owner.""" for backref_name in self.__exclusive_backrefs__: for related_obj in getattr(self, backref_name): curr_owner = getattr(related_obj, related_obj.__email_for__) @@ -426,17 +435,17 @@ def mark_blocked(cls, email: str) -> None: @overload @classmethod - def get_filter(cls, *, email: str) -> Optional[ColumnElement]: + def get_filter(cls, *, email: str) -> sa.ColumnElement[bool] | None: ... @overload @classmethod - def get_filter(cls, *, blake2b160: bytes) -> ColumnElement: + def get_filter(cls, *, blake2b160: bytes) -> sa.ColumnElement[bool]: ... @overload @classmethod - def get_filter(cls, *, email_hash: str) -> ColumnElement: + def get_filter(cls, *, email_hash: str) -> sa.ColumnElement[bool]: ... @overload @@ -444,20 +453,20 @@ def get_filter(cls, *, email_hash: str) -> ColumnElement: def get_filter( cls, *, - email: Optional[str], - blake2b160: Optional[bytes], - email_hash: Optional[str], - ) -> Optional[ColumnElement]: + email: str | None, + blake2b160: bytes | None, + email_hash: str | None, + ) -> sa.ColumnElement[bool] | None: ... @classmethod def get_filter( cls, *, - email: Optional[str] = None, - blake2b160: Optional[bytes] = None, - email_hash: Optional[str] = None, - ) -> Optional[ColumnElement]: + email: str | None = None, + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> sa.ColumnElement[bool] | None: """ Get an filter condition for retriving an :class:`EmailAddress`. @@ -480,7 +489,7 @@ def get_filter( def get( cls, email: str, - ) -> Optional[EmailAddress]: + ) -> EmailAddress | None: ... @overload @@ -489,7 +498,7 @@ def get( cls, *, blake2b160: bytes, - ) -> Optional[EmailAddress]: + ) -> EmailAddress | None: ... @overload @@ -498,17 +507,17 @@ def get( cls, *, email_hash: str, - ) -> Optional[EmailAddress]: + ) -> EmailAddress | None: ... @classmethod def get( cls, - email: Optional[str] = None, + email: str | None = None, *, - blake2b160: Optional[bytes] = None, - email_hash: Optional[str] = None, - ) -> Optional[EmailAddress]: + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> EmailAddress | None: """ Get an :class:`EmailAddress` instance by email address or its hash. @@ -519,7 +528,9 @@ def get( ).one_or_none() @classmethod - def get_canonical(cls, email: str, is_blocked: Optional[bool] = None) -> Query: + def get_canonical( + cls, email: str, is_blocked: bool | None = None + ) -> Query[EmailAddress]: """ Get :class:`EmailAddress` instances matching the canonical representation. @@ -535,7 +546,7 @@ def get_canonical(cls, email: str, is_blocked: Optional[bool] = None) -> Query: return query @classmethod - def _get_existing(cls, email: str) -> Optional[EmailAddress]: + def _get_existing(cls, email: str) -> EmailAddress | None: """ Get an existing :class:`EmailAddress` instance. @@ -569,7 +580,7 @@ def add(cls, email: str) -> EmailAddress: return new_email @classmethod - def add_for(cls, owner: Optional[object], email: str) -> EmailAddress: + def add_for(cls, owner: Account | None, email: str) -> EmailAddress: """ Create a new :class:`EmailAddress` after validation. @@ -590,29 +601,37 @@ def add_for(cls, owner: Optional[object], email: str) -> EmailAddress: @classmethod def validate_for( cls, - owner: Optional[object], + owner: Account | None, email: str, check_dns: bool = False, new: bool = False, - ) -> Union[ - bool, + ) -> ( Literal[ - 'nomx', 'not_new', 'soft_fail', 'hard_fail', 'invalid', 'nullmx', 'blocked' - ], - ]: + 'taken', + 'nomx', + 'not_new', + 'soft_fail', + 'hard_fail', + 'invalid', + 'nullmx', + 'blocked', + ] + | None + ): """ - Validate whether the email address is available to the given owner. + Validate whether the email address is available to the proposed owner. - Returns False if the address is blocked or in use by another owner, True if - available without issues, or a string value indicating the concern: + Returns None if available without issues, or a string value indicating the + concern: - 1. 'nomx': Email address is available, but has no MX records - 2. 'not_new': Email address is already attached to owner (if `new` is True) - 3. 'soft_fail': Known to be soft bouncing, requiring a warning message - 4. 'hard_fail': Known to be hard bouncing, usually a validation failure - 5. 'invalid': Available, but failed syntax validation - 6. 'nullmx': Available, but host explicitly says they will not accept email - 7. 'blocked': Email address is blocked from use + 1. 'taken': Email address has another owner + 2. 'nomx': Email address is available, but has no MX records + 3. 'not_new': Email address is already attached to owner (if `new` is True) + 4. 'soft_fail': Known to be soft bouncing, requiring a warning message + 5. 'hard_fail': Known to be hard bouncing, usually a validation failure + 6. 'invalid': Available, but failed syntax validation + 7. 'nullmx': Available, but host explicitly says they will not accept email + 8. 'blocked': Email address is blocked from use :param owner: Proposed owner of this email address (may be None) :param email: Email address to validate @@ -628,8 +647,8 @@ def validate_for( email, check_dns=check_dns, diagnose=True ) if diagnosis is True: - # No problems - return True + # There is no existing record, and the email address has no problems + return None # get_canonical won't return False when diagnose=True. Tell mypy: if cast(BaseDiagnosis, diagnosis).diagnosis_type == 'NO_MX_RECORD': return 'nomx' @@ -638,10 +657,10 @@ def validate_for( return 'invalid' # There's an existing? Is it available for this owner? if not existing.is_available_for(owner): - # Not available, so return False - return False + # Already taken by another owner + return 'taken' - # Available. Any other concerns? + # There is an existing but it's available for this owner. Any other concerns? if new: # Caller is asking to confirm this is not already belonging to this owner if existing.is_exclusive(): @@ -652,12 +671,12 @@ def validate_for( return 'soft_fail' if existing.delivery_state.HARD_FAIL: return 'hard_fail' - return True + return None @staticmethod def is_valid_email_address( email: str, check_dns: bool = False, diagnose: bool = False - ) -> Union[bool, BaseDiagnosis]: + ) -> bool | BaseDiagnosis: """ Return True if given email address is syntactically valid. @@ -689,21 +708,20 @@ class EmailAddressMixin: __tablename__: str #: This class has an optional dependency on EmailAddress - __email_optional__: bool = True + __email_optional__: ClassVar[bool] = True #: This class has a unique constraint on the fkey to EmailAddress - __email_unique__: bool = False + __email_unique__: ClassVar[bool] = False #: A relationship from this model is for the (single) owner at this attr - __email_for__: Optional[str] = None + __email_for__: ClassVar[str | None] = None #: If `__email_for__` is specified and this flag is True, the email address is #: considered exclusive to this owner and may not be used by any other owner - __email_is_exclusive__: bool = False + __email_is_exclusive__: ClassVar[bool] = False @declared_attr - def email_address_id( # pylint: disable=no-self-argument - cls, - ) -> sa.Column[int]: + @classmethod + def email_address_id(cls) -> Mapped[int | None]: """Foreign key to email_address table.""" - return sa.Column( + return sa.orm.mapped_column( sa.Integer, sa.ForeignKey('email_address.id', ondelete='SET NULL'), nullable=cls.__email_optional__, @@ -712,55 +730,48 @@ def email_address_id( # pylint: disable=no-self-argument ) @declared_attr - def email_address( # pylint: disable=no-self-argument - cls, - ) -> sa.orm.relationship[EmailAddress]: + @classmethod + def email_address(cls) -> Mapped[EmailAddress]: """Instance of :class:`EmailAddress` as a relationship.""" backref_name = 'used_in_' + cls.__tablename__ EmailAddress.__backrefs__.add(backref_name) if cls.__email_for__ and cls.__email_is_exclusive__: EmailAddress.__exclusive_backrefs__.add(backref_name) - return sa.orm.relationship(EmailAddress, backref=backref_name) - - @declared_attr - def email(cls) -> Mapped[Optional[str]]: # pylint: disable=no-self-argument - """Shorthand for ``self.email_address.email``.""" - - def email_get(self) -> Optional[str]: - """ - Shorthand for ``self.email_address.email``. - - Setting a value does the equivalent of one of these, depending on whether - the object requires the email address to be available to its owner:: + return relationship(EmailAddress, backref=backref_name) - self.email_address = EmailAddress.add(email) - self.email_address = EmailAddress.add_for(owner, email) + @property + def email(self) -> str | None: + """ + Shorthand for ``self.email_address.email``. - Where the owner is found from the attribute named in `cls.__email_for__`. - """ - if self.email_address: - return self.email_address.email - return None + Setting a value does the equivalent of one of these, depending on whether + the object requires the email address to be available to its owner:: - if cls.__email_for__: + self.email_address = EmailAddress.add(email) + self.email_address = EmailAddress.add_for(owner, email) - def email_set(self, value): - if value is not None: - self.email_address = EmailAddress.add_for( - getattr(self, cls.__email_for__), value - ) - else: - self.email_address = None + Where the owner is found from the attribute named in `cls.__email_for__`. + """ + if self.email_address: + return self.email_address.email + return None + + @email.setter + def email(self, value: str | None) -> None: + """Set an email address.""" + if self.__email_for__: + if value is not None: + self.email_address = EmailAddress.add_for( + getattr(self, self.__email_for__), value + ) + else: + self.email_address = None else: - - def email_set(self, value): - if value is not None: - self.email_address = EmailAddress.add(value) - else: - self.email_address = None - - return property(fget=email_get, fset=email_set) + if value is not None: + self.email_address = EmailAddress.add(value) + else: + self.email_address = None @property def email_address_reference_is_active(self) -> bool: @@ -773,7 +784,7 @@ def email_address_reference_is_active(self) -> bool: return True @property - def transport_hash(self) -> Optional[str]: + def transport_hash(self) -> str | None: """Email hash using the compatibility name for notifications framework.""" return ( self.email_address.email_hash @@ -788,7 +799,9 @@ def transport_hash(self) -> Optional[str]: @event.listens_for(EmailAddress.email, 'set') -def _validate_email(target, value: Any, old_value: Any, initiator) -> None: +def _validate_email( + target: EmailAddress, value: Any, old_value: Any, _initiator: Any +) -> None: # First: check if value is acceptable and email attribute can be set if not value and value is not None: # Only `None` is an acceptable falsy value @@ -836,16 +849,20 @@ def _validate_email(target, value: Any, old_value: Any, initiator) -> None: # We don't have to set target.email because SQLAlchemy will do that for us. -def _send_refcount_event_remove(target, value, initiator): +def _send_refcount_event_remove( + target: EmailAddress, _value: Any, _initiator: Any +) -> None: emailaddress_refcount_dropping.send(target) -def _send_refcount_event_before_delete(mapper_, connection, target): +def _send_refcount_event_before_delete( + _mapper: Any, _connection: Any, target: EmailAddressMixin +) -> None: if target.email_address: emailaddress_refcount_dropping.send(target.email_address) -@event.listens_for(mapper, 'after_configured') +@event.listens_for(Mapper, 'after_configured') def _setup_refcount_events() -> None: for backref_name in EmailAddress.__backrefs__: attr = getattr(EmailAddress, backref_name) @@ -853,7 +870,10 @@ def _setup_refcount_events() -> None: def _email_address_mixin_set_validator( - target, value: Optional[EmailAddress], old_value, initiator + target: EmailAddressMixin, + value: EmailAddress | None, + old_value: EmailAddress | None, + _initiator: Any, ) -> None: if value != old_value and target.__email_for__: if value is not None: @@ -865,8 +885,11 @@ def _email_address_mixin_set_validator( @event.listens_for(EmailAddressMixin, 'mapper_configured', propagate=True) def _email_address_mixin_configure_events( - mapper_, - cls: db.Model, # type: ignore[name-defined] -): + _mapper: Any, cls: type[EmailAddressMixin] +) -> None: event.listen(cls.email_address, 'set', _email_address_mixin_set_validator) event.listen(cls, 'before_delete', _send_refcount_event_before_delete) + + +if TYPE_CHECKING: + from .account import Account diff --git a/funnel/models/geoname.py b/funnel/models/geoname.py index 93a7fedde..b4726675f 100644 --- a/funnel/models/geoname.py +++ b/funnel/models/geoname.py @@ -2,16 +2,27 @@ from __future__ import annotations -from typing import Collection, Dict, List, Optional, Union, cast import re +from collections.abc import Collection +from decimal import Decimal +from typing import cast from sqlalchemy.dialects.postgresql import ARRAY -from sqlalchemy.orm import joinedload -from coaster.sqlalchemy import Query from coaster.utils import make_name -from . import BaseMixin, BaseNameMixin, Mapped, db, sa +from . import ( + BaseMixin, + BaseNameMixin, + GeonameModel, + Mapped, + Query, + backref, + db, + relationship, + sa, + types, +) from .helpers import quote_autocomplete_like __all__ = ['GeoName', 'GeoCountryInfo', 'GeoAdmin1Code', 'GeoAdmin2Code', 'GeoAltName'] @@ -31,36 +42,41 @@ } -class GeoCountryInfo(BaseNameMixin, db.Model): # type: ignore[name-defined] +class GeoCountryInfo(BaseNameMixin, GeonameModel): """Geoname record for a country.""" __tablename__ = 'geo_country_info' - __bind_key__ = 'geoname' - geonameid = sa.orm.synonym('id') - geoname = sa.orm.relationship( + geonameid: Mapped[int] = sa.orm.synonym('id') + geoname: Mapped[GeoName | None] = relationship( 'GeoName', uselist=False, primaryjoin='GeoCountryInfo.id == foreign(GeoName.id)', backref='has_country', ) - iso_alpha2 = sa.Column(sa.CHAR(2), unique=True) - iso_alpha3 = sa.Column(sa.CHAR(3), unique=True) - iso_numeric = sa.Column(sa.Integer) - fips_code = sa.Column(sa.Unicode(3)) - capital = sa.Column(sa.Unicode) - area_in_sqkm = sa.Column(sa.Numeric) - population = sa.Column(sa.BigInteger) - continent = sa.Column(sa.CHAR(2)) - tld = sa.Column(sa.Unicode(3)) - currency_code = sa.Column(sa.CHAR(3)) - currency_name = sa.Column(sa.Unicode) - phone = sa.Column(sa.Unicode(16)) - postal_code_format = sa.Column(sa.Unicode) - postal_code_regex = sa.Column(sa.Unicode) - languages = sa.Column(ARRAY(sa.Unicode, dimensions=1)) - neighbours = sa.Column(ARRAY(sa.CHAR(2), dimensions=1)) - equivalent_fips_code = sa.Column(sa.Unicode(3)) + iso_alpha2: Mapped[types.char2 | None] = sa.orm.mapped_column( + sa.CHAR(2), unique=True + ) + iso_alpha3: Mapped[types.char3 | None] = sa.orm.mapped_column(unique=True) + iso_numeric: Mapped[int | None] + fips_code: Mapped[types.str3 | None] + capital: Mapped[str | None] + area_in_sqkm: Mapped[Decimal | None] + population: Mapped[types.bigint | None] + continent: Mapped[types.char2 | None] + tld: Mapped[types.str3 | None] + currency_code: Mapped[types.char3 | None] + currency_name: Mapped[str | None] + phone: Mapped[types.str16 | None] + postal_code_format: Mapped[types.unicode | None] + postal_code_regex: Mapped[types.unicode | None] + languages: Mapped[list[str] | None] = sa.orm.mapped_column( + ARRAY(sa.Unicode, dimensions=1) + ) + neighbours: Mapped[list[str] | None] = sa.orm.mapped_column( + ARRAY(sa.CHAR(2), dimensions=1) + ) + equivalent_fips_code: Mapped[types.str3] __table_args__ = ( sa.Index( @@ -75,95 +91,92 @@ def __repr__(self) -> str: return f'' -class GeoAdmin1Code(BaseMixin, db.Model): # type: ignore[name-defined] +class GeoAdmin1Code(BaseMixin, GeonameModel): """Geoname record for 1st level administrative division (state, province).""" __tablename__ = 'geo_admin1_code' - __bind_key__ = 'geoname' - geonameid = sa.orm.synonym('id') - geoname = sa.orm.relationship( + geonameid: Mapped[int] = sa.orm.synonym('id') + geoname: Mapped[GeoName] = relationship( 'GeoName', uselist=False, primaryjoin='GeoAdmin1Code.id == foreign(GeoName.id)', backref='has_admin1code', viewonly=True, ) - title = sa.Column(sa.Unicode) - ascii_title = sa.Column(sa.Unicode) - country_id = sa.Column( + title = sa.orm.mapped_column(sa.Unicode) + ascii_title = sa.orm.mapped_column(sa.Unicode) + country_id = sa.orm.mapped_column( 'country', sa.CHAR(2), sa.ForeignKey('geo_country_info.iso_alpha2') ) - country = sa.orm.relationship('GeoCountryInfo') - admin1_code = sa.Column(sa.Unicode) + country: Mapped[GeoCountryInfo | None] = relationship('GeoCountryInfo') + admin1_code = sa.orm.mapped_column(sa.Unicode) def __repr__(self) -> str: """Return representation.""" return f'' -class GeoAdmin2Code(BaseMixin, db.Model): # type: ignore[name-defined] +class GeoAdmin2Code(BaseMixin, GeonameModel): """Geoname record for 2nd level administrative division (district, county).""" __tablename__ = 'geo_admin2_code' - __bind_key__ = 'geoname' - geonameid = sa.orm.synonym('id') - geoname = sa.orm.relationship( + geonameid: Mapped[int] = sa.orm.synonym('id') + geoname: Mapped[GeoName] = relationship( 'GeoName', uselist=False, primaryjoin='GeoAdmin2Code.id == foreign(GeoName.id)', backref='has_admin2code', viewonly=True, ) - title = sa.Column(sa.Unicode) - ascii_title = sa.Column(sa.Unicode) - country_id = sa.Column( + title = sa.orm.mapped_column(sa.Unicode) + ascii_title = sa.orm.mapped_column(sa.Unicode) + country_id = sa.orm.mapped_column( 'country', sa.CHAR(2), sa.ForeignKey('geo_country_info.iso_alpha2') ) - country = sa.orm.relationship('GeoCountryInfo') - admin1_code = sa.Column(sa.Unicode) - admin2_code = sa.Column(sa.Unicode) + country: Mapped[GeoCountryInfo | None] = relationship('GeoCountryInfo') + admin1_code = sa.orm.mapped_column(sa.Unicode) + admin2_code = sa.orm.mapped_column(sa.Unicode) def __repr__(self) -> str: """Return representation.""" return f'' -class GeoName(BaseNameMixin, db.Model): # type: ignore[name-defined] +class GeoName(BaseNameMixin, GeonameModel): """Geographical name record.""" __tablename__ = 'geo_name' - __bind_key__ = 'geoname' - - geonameid = sa.orm.synonym('id') - ascii_title = sa.Column(sa.Unicode) - latitude = sa.Column(sa.Numeric) - longitude = sa.Column(sa.Numeric) - fclass = sa.Column(sa.CHAR(1)) - fcode = sa.Column(sa.Unicode) - country_id = sa.Column( + + geonameid: Mapped[int] = sa.orm.synonym('id') + ascii_title = sa.orm.mapped_column(sa.Unicode) + latitude = sa.orm.mapped_column(sa.Numeric) + longitude = sa.orm.mapped_column(sa.Numeric) + fclass = sa.orm.mapped_column(sa.CHAR(1)) + fcode = sa.orm.mapped_column(sa.Unicode) + country_id = sa.orm.mapped_column( 'country', sa.CHAR(2), sa.ForeignKey('geo_country_info.iso_alpha2') ) - country = sa.orm.relationship('GeoCountryInfo') - cc2 = sa.Column(sa.Unicode) - admin1 = sa.Column(sa.Unicode) - admin1_ref = sa.orm.relationship( + country: Mapped[GeoCountryInfo | None] = relationship('GeoCountryInfo') + cc2 = sa.orm.mapped_column(sa.Unicode) + admin1 = sa.orm.mapped_column(sa.Unicode) + admin1_ref: Mapped[GeoAdmin1Code | None] = relationship( 'GeoAdmin1Code', uselist=False, primaryjoin='and_(GeoName.country_id == foreign(GeoAdmin1Code.country_id), ' 'GeoName.admin1 == foreign(GeoAdmin1Code.admin1_code))', viewonly=True, ) - admin1_id = sa.Column( + admin1_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('geo_admin1_code.id'), nullable=True ) - admin1code: Mapped[Optional[GeoAdmin1Code]] = sa.orm.relationship( + admin1code: Mapped[GeoAdmin1Code | None] = relationship( 'GeoAdmin1Code', uselist=False, foreign_keys=[admin1_id] ) - admin2 = sa.Column(sa.Unicode) - admin2_ref = sa.orm.relationship( + admin2 = sa.orm.mapped_column(sa.Unicode) + admin2_ref: Mapped[GeoAdmin2Code | None] = relationship( 'GeoAdmin2Code', uselist=False, primaryjoin='and_(GeoName.country_id == foreign(GeoAdmin2Code.country_id), ' @@ -171,20 +184,20 @@ class GeoName(BaseNameMixin, db.Model): # type: ignore[name-defined] 'GeoName.admin2 == foreign(GeoAdmin2Code.admin2_code))', viewonly=True, ) - admin2_id = sa.Column( + admin2_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('geo_admin2_code.id'), nullable=True ) - admin2code: Mapped[Optional[GeoAdmin2Code]] = sa.orm.relationship( + admin2code: Mapped[GeoAdmin2Code | None] = relationship( 'GeoAdmin2Code', uselist=False, foreign_keys=[admin2_id] ) - admin4 = sa.Column(sa.Unicode) - admin3 = sa.Column(sa.Unicode) - population = sa.Column(sa.BigInteger) - elevation = sa.Column(sa.Integer) - dem = sa.Column(sa.Integer) # Digital Elevation Model - timezone = sa.Column(sa.Unicode) - moddate = sa.Column(sa.Date) + admin4 = sa.orm.mapped_column(sa.Unicode) + admin3 = sa.orm.mapped_column(sa.Unicode) + population = sa.orm.mapped_column(sa.BigInteger) + elevation = sa.orm.mapped_column(sa.Integer) + dem = sa.orm.mapped_column(sa.Integer) # Digital Elevation Model + timezone = sa.orm.mapped_column(sa.Unicode) + moddate = sa.orm.mapped_column(sa.Date) __table_args__ = ( sa.Index( @@ -283,9 +296,9 @@ def make_name(self, reserved: Collection[str] = ()) -> None: """Create a unique name for this geoname record.""" if self.ascii_title: usetitle = self.use_title - if self.id: + if self.id: # pylint: disable=using-constant-test - def checkused(c): + def checkused(c: str) -> bool: return bool( c in reserved or GeoName.query.filter(GeoName.id != self.id) @@ -295,7 +308,7 @@ def checkused(c): else: - def checkused(c): + def checkused(c: str) -> bool: return bool( c in reserved or GeoName.query.filter_by(name=c).notempty() ) @@ -311,7 +324,7 @@ def __repr__(self) -> str: f' "{self.ascii_title}">' ) - def related_geonames(self) -> Dict[str, GeoName]: + def related_geonames(self) -> dict[str, GeoName]: """Return related geonames based on superior hierarchy (country, state, etc).""" related = {} if self.admin2code and self.admin2code.geonameid != self.geonameid: @@ -371,14 +384,14 @@ def as_dict(self, related=True, alternate_titles=True) -> dict: } @classmethod - def get(cls, name) -> Optional[GeoName]: + def get(cls, name) -> GeoName | None: """Get geoname record matching given URL stub name.""" return cls.query.filter_by(name=name).one_or_none() @classmethod def get_by_title( - cls, titles: Union[str, List[str]], lang: Optional[str] = None - ) -> List[GeoName]: + cls, titles: str | list[str], lang: str | None = None + ) -> list[GeoName]: """ Get geoname records matching the given titles. @@ -419,9 +432,9 @@ def get_by_title( def parse_locations( cls, q: str, - special: Optional[List[str]] = None, - lang: Optional[str] = None, - bias: Optional[List[str]] = None, + special: list[str] | None = None, + lang: str | None = None, + bias: list[str] | None = None, ): """ Parse a string and return annotations marking all identified locations. @@ -438,7 +451,7 @@ def parse_locations( while '' in tokens: tokens.remove('') # Remove blank tokens from beginning and end ltokens = [t.lower() for t in tokens] - results: List[Dict[str, object]] = [] + results: list[dict[str, object]] = [] counter = 0 limit = len(tokens) while counter < limit: @@ -458,9 +471,15 @@ def parse_locations( sa.or_(GeoAltName.lang == lang, GeoAltName.lang.is_(None)) ) .options( - joinedload('geoname').joinedload('country'), - joinedload('geoname').joinedload('admin1code'), - joinedload('geoname').joinedload('admin2code'), + sa.orm.joinedload(GeoAltName.geoname).joinedload( + GeoName.country + ), + sa.orm.joinedload(GeoAltName.geoname).joinedload( + GeoName.admin1code + ), + sa.orm.joinedload(GeoAltName.geoname).joinedload( + GeoName.admin2code + ), ) .all() ) @@ -472,9 +491,15 @@ def parse_locations( ) ) .options( - joinedload('geoname').joinedload('country'), - joinedload('geoname').joinedload('admin1code'), - joinedload('geoname').joinedload('admin2code'), + sa.orm.joinedload(GeoAltName.geoname).joinedload( + GeoName.country + ), + sa.orm.joinedload(GeoAltName.geoname).joinedload( + GeoName.admin1code + ), + sa.orm.joinedload(GeoAltName.geoname).joinedload( + GeoName.admin2code + ), ) .all() ) @@ -501,7 +526,7 @@ def parse_locations( { v: k for k, v in enumerate( - reversed(cast(List[str], bias)) + reversed(cast(list[str], bias)) ) }.get(a.geoname.country_id, -1), {lang: 0}.get(a.lang, 1), @@ -528,7 +553,7 @@ def parse_locations( return results @classmethod - def autocomplete(cls, q: str, lang: Optional[str] = None) -> Query: + def autocomplete(cls, prefix: str, lang: str | None = None) -> Query[GeoName]: """ Autocomplete a geoname record. @@ -538,7 +563,9 @@ def autocomplete(cls, q: str, lang: Optional[str] = None) -> Query: query = ( cls.query.join(cls.alternate_titles) .filter( - sa.func.lower(GeoAltName.title).like(quote_autocomplete_like(q.lower())) + sa.func.lower(GeoAltName.title).like( + quote_autocomplete_like(prefix.lower()) + ) ) .order_by(sa.desc(cls.population)) ) @@ -549,23 +576,24 @@ def autocomplete(cls, q: str, lang: Optional[str] = None) -> Query: return query -class GeoAltName(BaseMixin, db.Model): # type: ignore[name-defined] +class GeoAltName(BaseMixin, GeonameModel): """Additional names for any :class:`GeoName`.""" __tablename__ = 'geo_alt_name' - __bind_key__ = 'geoname' - geonameid = sa.Column(sa.Integer, sa.ForeignKey('geo_name.id'), nullable=False) - geoname = sa.orm.relationship( + geonameid = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('geo_name.id'), nullable=False + ) + geoname: Mapped[GeoName] = relationship( GeoName, - backref=sa.orm.backref('alternate_titles', cascade='all, delete-orphan'), + backref=backref('alternate_titles', cascade='all, delete-orphan'), ) - lang = sa.Column(sa.Unicode, nullable=True, index=True) - title = sa.Column(sa.Unicode, nullable=False) - is_preferred_name = sa.Column(sa.Boolean, nullable=False) - is_short_name = sa.Column(sa.Boolean, nullable=False) - is_colloquial = sa.Column(sa.Boolean, nullable=False) - is_historic = sa.Column(sa.Boolean, nullable=False) + lang = sa.orm.mapped_column(sa.Unicode, nullable=True, index=True) + title = sa.orm.mapped_column(sa.Unicode, nullable=False) + is_preferred_name = sa.orm.mapped_column(sa.Boolean, nullable=False) + is_short_name = sa.orm.mapped_column(sa.Boolean, nullable=False) + is_colloquial = sa.orm.mapped_column(sa.Boolean, nullable=False) + is_historic = sa.orm.mapped_column(sa.Boolean, nullable=False) __table_args__ = ( sa.Index( diff --git a/funnel/models/helpers.py b/funnel/models/helpers.py index 512d7ef5a..3fb9d038e 100644 --- a/funnel/models/helpers.py +++ b/funnel/models/helpers.py @@ -2,41 +2,28 @@ from __future__ import annotations -from dataclasses import dataclass -from textwrap import dedent -from typing import ( - Any, - Callable, - ClassVar, - Dict, - Iterable, - List, - Optional, - Set, - Type, - TypeVar, -) import os.path import re +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from textwrap import dedent +from typing import Any, ClassVar, TypeVar, cast -from sqlalchemy import DDL, Text, event +from better_profanity import profanity +from furl import furl +from markupsafe import Markup, escape as html_escape +from sqlalchemy.dialects.postgresql import TSQUERY from sqlalchemy.dialects.postgresql.base import ( RESERVED_WORDS as POSTGRESQL_RESERVED_WORDS, ) from sqlalchemy.ext.mutable import MutableComposite -from sqlalchemy.orm import composite - -from flask import Markup -from flask import escape as html_escape - -from better_profanity import profanity -from furl import furl +from sqlalchemy.orm import Mapped, composite from zxcvbn import zxcvbn from .. import app from ..typing import T -from ..utils import MarkdownConfig, markdown_escape -from . import UrlType, db, sa +from ..utils import MarkdownConfig, MarkdownString, markdown_escape +from . import Model, UrlType, sa __all__ = [ 'RESERVED_NAMES', @@ -48,8 +35,9 @@ 'add_search_trigger', 'visual_field_delimiter', 'valid_name', - 'valid_username', + 'valid_account_name', 'quote_autocomplete_like', + 'quote_autocomplete_tsquery', 'ImgeeFurl', 'ImgeeType', 'MarkdownCompositeBase', @@ -58,7 +46,7 @@ 'MarkdownCompositeInline', ] -RESERVED_NAMES: Set[str] = { +RESERVED_NAMES: set[str] = { '_baseframe', 'about', 'account', @@ -164,7 +152,7 @@ class PasswordCheckType: is_weak: bool score: int # One of 0, 1, 2, 3, 4 warning: str - suggestions: List[str] + suggestions: list[str] #: Minimum length for a password @@ -177,7 +165,7 @@ class PasswordCheckType: def check_password_strength( - password: str, user_inputs: Optional[Iterable[str]] = None + password: str, user_inputs: Iterable[str] | None = None ) -> PasswordCheckType: """Check the strength of a password using zxcvbn.""" result = zxcvbn(password, user_inputs) @@ -195,7 +183,7 @@ def check_password_strength( # re.IGNORECASE needs re.ASCII because of a quirk in the characters it matches. # https://docs.python.org/3/library/re.html#re.I -_username_valid_re = re.compile('^[a-z0-9]([a-z0-9-]*[a-z0-9])?$', re.I | re.A) +_account_name_valid_re = re.compile('^[a-z0-9][a-z0-9_]*$', re.I | re.A) _name_valid_re = re.compile('^[a-z0-9]([a-z0-9-]*[a-z0-9])?$', re.A) @@ -212,7 +200,7 @@ def check_password_strength( visual_field_delimiter = ' ¦ ' -def add_to_class(cls: Type, name: Optional[str] = None) -> Callable[[T], T]: +def add_to_class(cls: type, name: str | None = None) -> Callable[[T], T]: """ Add a new method to a class via a decorator. Takes an optional attribute name. @@ -229,7 +217,7 @@ def existing_class_new_property(self): """ def decorator(attr: T) -> T: - use_name: Optional[str] = name or getattr(attr, '__name__', None) + use_name: str | None = name or getattr(attr, '__name__', None) if not use_name: # pragma: no cover # None or '' not allowed raise ValueError(f"Could not determine name for {attr!r}") @@ -249,6 +237,10 @@ def reopen(cls: ReopenedType) -> Callable[[TempType], ReopenedType]: """ Move the contents of the decorated class into an existing class and return it. + .. deprecated:: + This function is deprecated and should not be used as it is incompatible with + PEP 484 type hinting. + Usage:: @reopen(ExistingClass) @@ -313,13 +305,13 @@ def decorator(temp_cls: TempType) -> ReopenedType: return decorator -def valid_username(candidate: str) -> bool: +def valid_account_name(candidate: str) -> bool: """ Check if a username is valid. - Letters, numbers and non-terminal hyphens only. + Letters, numbers and underscores only. """ - return not _username_valid_re.search(candidate) is None + return _account_name_valid_re.search(candidate) is not None def valid_name(candidate: str) -> bool: @@ -328,7 +320,7 @@ def valid_name(candidate: str) -> bool: Lowercase letters, numbers and non-terminal hyphens only. """ - return not _name_valid_re.search(candidate) is None + return _name_valid_re.search(candidate) is not None def pgquote(identifier: str) -> str: @@ -336,53 +328,78 @@ def pgquote(identifier: str) -> str: return f'"{identifier}"' if identifier in POSTGRESQL_RESERVED_WORDS else identifier -def quote_autocomplete_like(query): +def quote_autocomplete_like(prefix: str, midway: bool = False) -> str: """ Construct a LIKE query string for prefix-based matching (autocomplete). + :param midway: Search midway using the ``%letters%`` syntax. This requires a + trigram index to be efficient + Usage:: - column.like(quote_autocomplete_like(query)) + column.like(quote_autocomplete_like(prefix)) For case-insensitive queries, add an index on LOWER(column) and use:: - sa.func.lower(column).like(sa.func.lower(quote_autocomplete_like(query))) + sa.func.lower(column).like(sa.func.lower(quote_autocomplete_like(prefix))) + + This function will return an empty string if the prefix has no content after + stripping whitespace and special characters. It is prudent to test before usage:: + + like_query = quote_autocomplete_like(prefix) + if like_query: + # Proceed with query + query = Model.query.filter( + sa.func.lower(Model.column).like(sa.func.lower(like_query)) + ) """ # Escape the '%' and '_' wildcards in SQL LIKE clauses. # Some SQL dialects respond to '[' and ']', so remove them. # Suffix a '%' to make a prefix-match query. - return ( - query.replace('%', r'\%').replace('_', r'\_').replace('[', '').replace(']', '') + like_query = ( + prefix.replace('\\', r'\\') + .replace('%', r'\%') + .replace('_', r'\_') + .replace('[', '') + .replace(']', '') + '%' ) + lstrip_like_query = like_query.lstrip() + if lstrip_like_query == '%': + return '' + if midway: + return '%' + like_query + return lstrip_like_query -def quote_autocomplete_tsquery(query: str) -> str: +def quote_autocomplete_tsquery(prefix: str) -> TSQUERY: """Return a PostgreSQL tsquery suitable for autocomplete-type matches.""" - with db.session.no_autoflush: - return db.session.query( - sa.func.cast(sa.func.phraseto_tsquery(query or ''), Text) + ':*' - ).scalar() + return cast( + TSQUERY, + sa.func.cast( + sa.func.concat(sa.func.phraseto_tsquery('simple', prefix or ''), ':*'), + TSQUERY, + ), + ) -def add_search_trigger( - model: db.Model, column_name: str # type: ignore[name-defined] -) -> Dict[str, str]: +def add_search_trigger(model: type[Model], column_name: str) -> dict[str, str]: """ Add a search trigger and returns SQL for use in migrations. Typical use:: - class MyModel(db.Model): # type: ignore[name-defined] + class MyModel(Model): ... - search_vector = sa.orm.deferred(sa.Column( + search_vector: Mapped[TSVectorType] = sa.orm.mapped_column( TSVectorType( 'name', 'title', *indexed_columns, weights={'name': 'A', 'title': 'B'}, regconfig='english' ), nullable=False, - )) + deferred=True, + ) __table_args__ = ( sa.Index( @@ -441,19 +458,20 @@ class MyModel(db.Model): # type: ignore[name-defined] END $$ LANGUAGE plpgsql; - CREATE TRIGGER {trigger_name} BEFORE INSERT OR UPDATE ON {table_name} - FOR EACH ROW EXECUTE PROCEDURE {function_name}(); + CREATE TRIGGER {trigger_name} BEFORE INSERT OR UPDATE OF {source_columns} + ON {table_name} FOR EACH ROW EXECUTE PROCEDURE {function_name}(); '''.format( # nosec function_name=pgquote(function_name), column_name=pgquote(column_name), trigger_expr=trigger_expr, trigger_name=pgquote(trigger_name), + source_columns=', '.join(pgquote(col) for col in column.type.columns), table_name=pgquote(model.__tablename__), ) ) update_statement = ( - f'UPDATE {pgquote(model.__tablename__)}' + f'UPDATE {pgquote(model.__tablename__)}' # nosec f' SET {pgquote(column_name)} = {update_expr};' ) @@ -464,22 +482,16 @@ class MyModel(db.Model): # type: ignore[name-defined] ''' ) - # FIXME: `DDL().execute_if` accepts a string dialect, but sqlalchemy-stubs - # incorrectly declares the type as `Optional[Dialect]` - # https://github.com/dropbox/sqlalchemy-stubs/issues/181 - - event.listen( + sa.event.listen( model.__table__, 'after_create', - DDL(trigger_function).execute_if( - dialect='postgresql' # type: ignore[arg-type] - ), + sa.DDL(trigger_function).execute_if(dialect='postgresql'), ) - event.listen( + sa.event.listen( model.__table__, 'before_drop', - DDL(drop_statement).execute_if(dialect='postgresql'), # type: ignore[arg-type] + sa.DDL(drop_statement).execute_if(dialect='postgresql'), ) return { @@ -497,27 +509,35 @@ class MessageComposite: :param tag: Optional wrapper tag for HTML rendering """ - def __init__(self, text: str, tag: Optional[str] = None): + def __init__(self, text: str, tag: str | None = None) -> None: self.text = text self.tag = tag - def __markdown__(self) -> str: + def __markdown__(self) -> MarkdownString: """Return Markdown source (for escaper).""" return markdown_escape(self.text) - def __html__(self) -> str: + def __markdown_format__(self, format_spec: str) -> str: + """Implement format_spec support as required by MarkdownString.""" + return self.__markdown__().__markdown_format__(format_spec) + + def __html__(self) -> Markup: """Return HTML version of string.""" # Localize lazy string on demand tag = self.tag if tag: - return f'

<{tag}>{html_escape(self.text)}

' - return f'

{html_escape(self.text)}

' + return Markup(f'

<{tag}>{html_escape(self.text)}

') + return Markup(f'

{html_escape(self.text)}

') + + def __html_format__(self, format_spec: str) -> str: + """Implement format_spec support as required by Markup.""" + return self.__html__().__html_format__(format_spec) @property def html(self) -> Markup: return Markup(self.__html__()) - def __json__(self) -> Dict[str, Any]: + def __json__(self) -> dict[str, Any]: """Return JSON-compatible rendering of contents.""" return {'text': self.text, 'html': self.__html__()} @@ -525,7 +545,7 @@ def __json__(self) -> Dict[str, Any]: class ImgeeFurl(furl): """Furl with a resize method specifically for Imgee URLs.""" - def resize(self, width: int, height: Optional[int] = None) -> furl: + def resize(self, width: int, height: int | None = None) -> furl: """ Return image url with `?size=WxH` suffixed to it. @@ -545,7 +565,7 @@ class ImgeeType(UrlType): # pylint: disable=abstract-method url_parser = ImgeeFurl cache_ok = True - def process_bind_param(self, value, dialect): + def process_bind_param(self, value: Any, dialect: Any) -> furl: value = super().process_bind_param(value, dialect) if value: allowed_domains = app.config.get('IMAGE_URL_DOMAINS', []) @@ -560,68 +580,82 @@ def process_bind_param(self, value, dialect): return value +_MC = TypeVar('_MC', bound='MarkdownCompositeBase') + + class MarkdownCompositeBase(MutableComposite): """Represents Markdown text and rendered HTML as a composite column.""" config: ClassVar[MarkdownConfig] - def __init__(self, text, html=None): + def __init__(self, text: str | None, html: str | None = None) -> None: """Create a composite.""" if html is None: self.text = text # This will regenerate HTML else: self._text = text - self._html = html + self._html: str | None = html - # Return column values for SQLAlchemy to insert into the database - def __composite_values__(self): - """Return composite values.""" + def __composite_values__(self) -> tuple[str | None, str | None]: + """Return composite values for SQLAlchemy.""" return (self._text, self._html) # Return a string representation of the text (see class decorator) - def __str__(self): + def __str__(self) -> str: """Return string representation.""" return self._text or '' - def __markdown__(self): + def __markdown__(self) -> str: """Return source Markdown (for escaper).""" return self._text or '' - # Return a HTML representation of the text - def __html__(self): + def __markdown_format__(self, format_spec: str) -> str: + """Implement format_spec support as required by MarkdownString.""" + # This call's MarkdownString's __format__ instead of __markdown_format__ as the + # content has not been manipulated from the source string + return self.__markdown__().__format__(format_spec) + + def __html__(self) -> str: """Return HTML representation.""" return self._html or '' + def __html_format__(self, format_spec: str) -> str: + """Implement format_spec support as required by Markup.""" + # This call's Markup's __format__ instead of __html_format__ as the + # content has not been manipulated from the source string + return self.__html__().__format__(format_spec) + # Return a Markup string of the HTML @property - def html(self): + def html(self) -> Markup | None: """Return HTML as a read-only property.""" return Markup(self._html) if self._html is not None else None @property - def text(self): + def text(self) -> str | None: """Return text as a property.""" return self._text @text.setter - def text(self, value): + def text(self, value: str | None) -> None: """Set the text value.""" self._text = None if value is None else str(value) self._html = self.config.render(self._text) self.changed() - def __json__(self) -> Dict[str, Optional[str]]: + def __json__(self) -> dict[str, str | None]: """Return JSON-compatible rendering of composite.""" return {'text': self._text, 'html': self._html} - # Compare text value - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Compare for equality.""" - return isinstance(other, self.__class__) and ( - self.__composite_values__() == other.__composite_values__() + return ( + isinstance(other, self.__class__) + and (self.__composite_values__() == other.__composite_values__()) + or self._text == other ) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: """Compare for inequality.""" return not self.__eq__(other) @@ -629,37 +663,62 @@ def __ne__(self, other): # tested here as we don't use them. # https://docs.sqlalchemy.org/en/13/orm/extensions/mutable.html#id1 - def __getstate__(self): + def __getstate__(self) -> tuple[str | None, str | None]: """Get state for pickling.""" # Return state for pickling return (self._text, self._html) - def __setstate__(self, state): + def __setstate__(self, state: tuple[str | None, str | None]) -> None: """Set state from pickle.""" # Set state from pickle self._text, self._html = state self.changed() - def __bool__(self): + def __bool__(self) -> bool: """Return boolean value.""" return bool(self._text) @classmethod - def coerce(cls, key, value): + def coerce(cls: type[_MC], key: str, value: Any) -> _MC: """Allow a composite column to be assigned a string value.""" return cls(value) + # TODO: Add `nullable` as a keyword parameter and add overloads for returning + # Mapped[str] or Mapped[str | None] based on nullable + @classmethod def create( - cls, name: str, deferred: bool = False, group: Optional[str] = None, **kwargs - ) -> composite: + cls: type[_MC], + name: str, + deferred: bool = False, + deferred_group: str | None = None, + **kwargs, + ) -> tuple[sa.orm.Composite[_MC], Mapped[str], Mapped[str]]: """Create a composite column and backing individual columns.""" - return composite( - cls, - sa.Column(name + '_text', sa.UnicodeText, **kwargs), - sa.Column(name + '_html', sa.UnicodeText, **kwargs), + col_text = sa.orm.mapped_column( + name + '_text', + sa.UnicodeText, + deferred=deferred, + deferred_group=deferred_group, + **kwargs, + ) + col_html = sa.orm.mapped_column( + name + '_html', + sa.UnicodeText, deferred=deferred, - group=group or name, + deferred_group=deferred_group, + **kwargs, + ) + return ( + composite( + cls, + col_text, + col_html, + deferred=deferred, + group=deferred_group, + ), + col_text, + col_html, ) diff --git a/funnel/models/label.py b/funnel/models/label.py index a4013d09a..cb586b160 100644 --- a/funnel/models/label.py +++ b/funnel/models/label.py @@ -2,14 +2,19 @@ from __future__ import annotations -from typing import Union - -from sqlalchemy.ext.orderinglist import ordering_list -from sqlalchemy.sql import case, exists +from sqlalchemy.ext.orderinglist import OrderingList, ordering_list from coaster.sqlalchemy import with_roles -from . import BaseScopedNameMixin, TSVectorType, db, hybrid_property, sa +from . import ( + BaseScopedNameMixin, + Mapped, + Model, + TSVectorType, + hybrid_property, + relationship, + sa, +) from .helpers import add_search_trigger, reopen, visual_field_delimiter from .project import Project from .project_membership import project_child_role_map @@ -17,7 +22,7 @@ proposal_label = sa.Table( 'proposal_label', - db.Model.metadata, # type: ignore[has-type] + Model.metadata, sa.Column( 'proposal_id', sa.Integer, @@ -37,36 +42,35 @@ ) -class Label( - BaseScopedNameMixin, - db.Model, # type: ignore[name-defined] -): +class Label(BaseScopedNameMixin, Model): __tablename__ = 'label' - project_id = sa.Column( + project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False ) # Backref from project is defined in the Project model with an ordering list - project: sa.orm.relationship[Project] = with_roles( - sa.orm.relationship(Project), grants_via={None: project_child_role_map} + project: Mapped[Project] = with_roles( + relationship(Project), grants_via={None: project_child_role_map} ) # `parent` is required for # :meth:`~coaster.sqlalchemy.mixins.BaseScopedNameMixin.make_name()` - parent = sa.orm.synonym('project') + parent: Mapped[Project] = sa.orm.synonym('project') #: Parent label's id. Do not write to this column directly, as we don't have the #: ability to : validate the value within the app. Always use the :attr:`main_label` #: relationship. - main_label_id = sa.Column( + main_label_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('label.id', ondelete='CASCADE'), index=True, nullable=True, ) + main_label: Mapped[Label] = relationship( + remote_side='Label.id', back_populates='options' + ) # See https://docs.sqlalchemy.org/en/13/orm/self_referential.html - options = sa.orm.relationship( - 'Label', - backref=sa.orm.backref('main_label', remote_side='Label.id'), + options: Mapped[OrderingList[Label]] = relationship( + back_populates='main_label', order_by='Label.seq', passive_deletes=True, collection_class=ordering_list('seq', count_from=1), @@ -77,48 +81,53 @@ class Label( # add_primary_relationship) #: Sequence number for this label, used in UI for ordering - seq = sa.Column(sa.Integer, nullable=False) + seq = sa.orm.mapped_column(sa.Integer, nullable=False) # A single-line description of this label, shown when picking labels (optional) - description = sa.Column(sa.UnicodeText, nullable=False, default="") + description = sa.orm.mapped_column(sa.UnicodeText, nullable=False, default='') #: Icon for displaying in space-constrained UI. Contains one emoji symbol. #: Since emoji can be composed from multiple symbols, there is no length #: limit imposed here - icon_emoji = sa.Column(sa.UnicodeText, nullable=True) + icon_emoji = sa.orm.mapped_column(sa.UnicodeText, nullable=True) #: Restricted mode specifies that this label may only be applied by someone with #: an editorial role (TODO: name the role). If this label is a parent, it applies #: to all its children - _restricted = sa.Column('restricted', sa.Boolean, nullable=False, default=False) + _restricted = sa.orm.mapped_column( + 'restricted', sa.Boolean, nullable=False, default=False + ) #: Required mode signals to UI that if this label is a parent, one of its #: children must be mandatorily applied to the proposal. The value of this #: field must be ignored if the label is not a parent - _required = sa.Column('required', sa.Boolean, nullable=False, default=False) + _required = sa.orm.mapped_column( + 'required', sa.Boolean, nullable=False, default=False + ) #: Archived mode specifies that the label is no longer available for use #: although all the previous records will stay in database. - _archived = sa.Column('archived', sa.Boolean, nullable=False, default=False) + _archived = sa.orm.mapped_column( + 'archived', sa.Boolean, nullable=False, default=False + ) - search_vector = sa.orm.deferred( - sa.Column( - TSVectorType( - 'name', - 'title', - 'description', - weights={'name': 'A', 'title': 'A', 'description': 'B'}, - regconfig='english', - hltext=lambda: sa.func.concat_ws( - visual_field_delimiter, Label.title, Label.description - ), + search_vector: Mapped[TSVectorType] = sa.orm.mapped_column( + TSVectorType( + 'name', + 'title', + 'description', + weights={'name': 'A', 'title': 'A', 'description': 'B'}, + regconfig='english', + hltext=lambda: sa.func.concat_ws( + visual_field_delimiter, Label.title, Label.description ), - nullable=False, - ) + ), + nullable=False, + deferred=True, ) #: Proposals that this label is attached to - proposals = sa.orm.relationship( + proposals: Mapped[list[Proposal]] = relationship( Proposal, secondary=proposal_label, back_populates='labels' ) @@ -157,13 +166,13 @@ class Label( } @property - def title_for_name(self): + def title_for_name(self) -> str: if self.main_label: return f"{self.main_label.title}/{self.title}" return self.title @property - def form_label_text(self): + def form_label_text(self) -> str: return ( self.icon_emoji + " " + self.title if self.icon_emoji is not None @@ -171,88 +180,92 @@ def form_label_text(self): ) @property - def has_proposals(self): + def has_proposals(self) -> bool: if not self.has_options: return bool(self.proposals) return any(bool(option.proposals) for option in self.options) @hybrid_property - def restricted(self): - return ( # pylint: disable=protected-access - self.main_label._restricted if self.main_label else self._restricted - ) + def restricted(self) -> bool: + # pylint: disable=protected-access + return self.main_label._restricted if self.main_label else self._restricted - @restricted.setter - def restricted(self, value): + @restricted.inplace.setter + def _restricted_setter(self, value: bool) -> None: if self.main_label: raise ValueError("This flag must be set on the parent") self._restricted = value - @restricted.expression - def restricted(cls): # noqa: N805 # pylint: disable=no-self-argument - return case( - [ - ( - cls.main_label_id.isnot(None), - sa.select([Label._restricted]) - .where(Label.id == cls.main_label_id) - .as_scalar(), - ) - ], + @restricted.inplace.expression + @classmethod + def _restricted_expression(cls) -> sa.Case: + """Return SQL Expression.""" + return sa.case( + ( + cls.main_label_id.is_not(None), + sa.select(Label._restricted) + .where(Label.id == cls.main_label_id) + .scalar_subquery(), + ), else_=cls._restricted, ) @hybrid_property - def archived(self): + def archived(self) -> bool: + """Test if this label or parent label is archived.""" return self._archived or ( self.main_label._archived # pylint: disable=protected-access if self.main_label else False ) - @archived.setter - def archived(self, value): + @archived.inplace.setter + def _archived_setter(self, value: bool) -> None: + """Archive this label.""" self._archived = value - @archived.expression - def archived(cls): # noqa: N805 # pylint: disable=no-self-argument - return case( - [ - (cls._archived.is_(True), cls._archived), - ( - cls.main_label_id.isnot(None), - sa.select([Label._archived]) - .where(Label.id == cls.main_label_id) - .as_scalar(), - ), - ], + @archived.inplace.expression + @classmethod + def _archived_expression(cls) -> sa.Case: + """Return SQL Expression.""" + return sa.case( + (cls._archived.is_(True), cls._archived), + ( + cls.main_label_id.is_not(None), + sa.select(Label._archived) + .where(Label.id == cls.main_label_id) + .scalar_subquery(), + ), else_=cls._archived, ) @hybrid_property - def has_options(self): + def has_options(self) -> bool: return bool(self.options) - @has_options.expression - def has_options(cls): # noqa: N805 # pylint: disable=no-self-argument - return exists().where(Label.main_label_id == cls.id) + @has_options.inplace.expression + @classmethod + def _has_options_expression(cls) -> sa.Exists: + """Return SQL Expression.""" + return sa.exists().where(Label.main_label_id == cls.id) @property - def is_main_label(self): + def is_main_label(self) -> bool: return not self.main_label @hybrid_property - def required(self): + def required(self) -> bool: + # pylint: disable=using-constant-test return self._required if self.has_options else False - @required.setter - def required(self, value): + @required.inplace.setter + def _required_setter(self, value: bool) -> None: if value and not self.has_options: raise ValueError("Labels without options cannot be mandatory") self._required = value @property - def icon(self): + def icon(self) -> str: """ Return an icon for displaying the label in space-constrained UI. @@ -273,7 +286,8 @@ def __repr__(self) -> str: return f'
@@ -48,11 +49,11 @@
-

Info

+

{% trans %}Info{% endtrans %}

- {{ faicon(icon='user', css_class="icon-img--smaller")}} + {{ faicon(icon='user', css_class="icon-img--smaller") }} {{ current_auth.user.fullname }}

@@ -109,17 +110,20 @@

-

{% trans %}Connected accounts{% endtrans %}

+

{% trans %}Connected accounts{% endtrans %}

    {% for extid in current_auth.user.externalids %}
  1. {{ faicon(icon=extid.service, icon_size='body2', baseline=false, css_class="mui--text-light icon-img icon-img--smaller") }} - {{ extid.username or (extid.service in login_registry and login_registry[extid.service]['title']) or extid.service }} {% trans last_used_at=extid.last_used_at|age %}Last used {{ last_used_at }}{% endtrans %} + + {{ extid.username or (extid.service in login_registry and login_registry[extid.service]['title']) or extid.service }} + {% trans last_used_at=extid.last_used_at|age %}Last used {{ last_used_at }}{% endtrans %} + {{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }} + aria-label="{% trans %}Remove{% endtrans %}">{{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }}
  2. {% endfor %}
@@ -134,7 +138,7 @@ aria-label="{% trans title=provider.title %}Login using {{ title }}{% endtrans %}"> {{ provider.title }} + aria-hidden="true"/> {% endfor %}
@@ -145,7 +149,7 @@
-

{% trans %}Email addresses{% endtrans %}

+

{% trans %}Email addresses{% endtrans %}

{{ useremail }} {% if useremail.primary %} - {{ faicon(icon='check-circle-solid', icon_size='subhead', baseline=false, css_class="mui--text-success input-align-icon") }} + {{ faicon(icon='check-circle-solid', icon_size='subhead', baseline=false, css_class="mui--text-success input-align-icon") }} {%- endif -%} {%- if has_multiple_verified_contacts %} {{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }} + aria-label="{% trans %}Remove{% endtrans %}">{{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }} {%- endif %} {% endfor %} {% for useremail in current_auth.user.emailclaims %}
  • - {{ useremail }} {% trans %}(pending verification){% endtrans %} + {{ useremail }} {% trans %}(pending verification){% endtrans %} {{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }} + aria-label="{% trans %}Remove{% endtrans %}" + class="mui--pull-right"> + {{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }} +
  • {% endfor %} @@ -190,14 +196,13 @@ {% if current_auth.user.emails %} {% endif %} {% trans %}Add an email address{% endtrans %} + href="{{ url_for('add_email') }}" data-cy="add-new-email">{% trans %}Add an email address{% endtrans %}
    @@ -206,7 +211,7 @@
    -

    {% trans %}Mobile numbers{% endtrans %}

    +

    {% trans %}Mobile numbers{% endtrans %}

    {% if userphone.primary %} - {{ faicon(icon='check-circle-solid', icon_size='subhead', baseline=false, css_class="mui--text-success input-align-icon") }} + {{ faicon(icon='check-circle-solid', icon_size='subhead', baseline=false, css_class="mui--text-success input-align-icon") }} {%- endif -%} {% if has_multiple_verified_contacts -%} - {{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }} + + {{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }} + {%- endif %} {% endfor %} @@ -248,7 +253,7 @@ {% endif %} {% trans %}Add a mobile number{% endtrans %} + href="{{ url_for('add_phone') }}" data-cy="add-new-phone">{% trans %}Add a mobile number{% endtrans %}
    @@ -259,7 +264,7 @@
    -

    +

    {% trans %}Connected apps{% endtrans %}

    @@ -273,6 +278,7 @@ rel="nofollow" {% if auth_token.auth_client.trusted -%} title="{% trans %}Made by Hasgeek{% endtrans %}" + aria-label="{% trans %}Made by Hasgeek{% endtrans %}" {%- endif -%}>{{ auth_token.auth_client.title }} {%- if auth_token.auth_client.trusted %} {{ faicon('badge-check-solid') }} @@ -290,7 +296,10 @@
    @@ -303,7 +312,7 @@
    -

    +

    {% trans %}Login sessions{% endtrans %}

    @@ -313,8 +322,14 @@ {{ logout_form.hidden_tag() }}
      - {%- for user_session in current_auth.user.active_user_sessions %} - {%- with ua=user_session.views.user_agent_details(), login_service=user_session.views.login_service(), location=user_session.views.location(), user_agent=user_session.user_agent, since=user_session.created_at|age, last_active=user_session.accessed_at|age %} + {%- for login_session in current_auth.user.active_login_sessions %} + {%- with + ua=login_session.views.user_agent_details(), + login_service=login_session.views.login_service(), + location=login_session.views.location(), + user_agent=login_session.user_agent, + since=login_session.created_at|age, + last_active=login_session.accessed_at|age %}
    -
    - {# Disabled until feature is ready for public use
    -
    -

    {% trans %}Delete{% endtrans %}

    +
    +

    {% trans %}Delete account{% endtrans %}

    + {{ faicon(icon='exclamation-triangle', icon_size='subhead', baseline=true, css_class="mui--text-danger input-align-icon") }}
    -

    {%- trans %}{% endtrans %}

    - {% trans %}Delete my account{% endtrans %} +

    {% trans -%} + If you no longer need this account, you can delete it. If you have a duplicate account, you can merge it by adding the same phone number or email address here. No deletion necessary. + {%- endtrans %}

    +
    +
    +
    -#} +
    {%- endblock basecontent %} {% block footerscripts -%} + {{ ajaxform('email-primary-form', request) }} {{ ajaxform('phone-primary-form', request) }} - + {{ ajaxform(ref_id=ref_id, request=request, force=ajax) }} + {%- if form and 'recaptcha' in form %} + {% block recaptcha %}{{ recaptcha(ref_id=ref_id) }}{% endblock recaptcha %} + {%- endif %} {% endblock footerscripts %} diff --git a/funnel/templates/account_menu.html.jinja2 b/funnel/templates/account_menu.html.jinja2 index b1fc2a98c..2971a01f8 100644 --- a/funnel/templates/account_menu.html.jinja2 +++ b/funnel/templates/account_menu.html.jinja2 @@ -1,6 +1,6 @@ -{%- from "macros.html.jinja2" import faicon, useravatar, csrf_tag %} +{%- from "macros.html.jinja2" import faicon, useravatar, csrf_tag, img_size %} {%- if current_auth -%} - {% if current_auth.user.profile %} + {% if current_auth.user.name %}
  • @@ -44,18 +44,18 @@
  • {%- for orgmem in orgmemlist.recent %}
  • - - {%- if orgmem.organization.profile.logo_url.url %} - {{ orgmem.organization.title }} + {%- if orgmem.account.logo_url.url %} + {{ orgmem.account.title }} {% else %} {{ orgmem.organization.title }} + alt="{{ orgmem.account.title }}"/> {% endif %} - {{ orgmem.organization.profile.title }} + {{ orgmem.account.title }}
  • {%- endfor %} @@ -65,16 +65,16 @@ class="header__dropdown__item header__dropdown__item--flex header__dropdown__item--morepadding mui--text-dark nounderline"> {%- for orgmem in orgmemlist.overflow %} - {%- if orgmem.organization.profile.logo_url.url %} - {{ orgmem.organization.title }} + {%- if orgmem.account.logo_url.url %} + {{ orgmem.account.title }} {% else %} {{ orgmem.organization.title }} + alt="{{ orgmem.account.title }}"/> {% endif %} {%- endfor %} - {%- if orgmemlist.extra_count -%}{{ faicon(icon='ellipsis-h', icon_size='subhead', baseline=false, css_class="header__dropdown__item__more-icon header__dropdown__item__more-icon--align") }}{{ faicon(icon='plus', icon_size='caption') }} {% trans count=orgmemlist.extra_count %}{{ count }} more{% pluralize count %}{{ count }} more{% endtrans %}{%- endif %} + {%- if orgmemlist.extra_count -%}{{ faicon(icon='ellipsis-h', icon_size='subhead', baseline=false, css_class="header__dropdown__item__more-icon header__dropdown__item__more-icon--align") }}{{ faicon(icon='plus', icon_size='caption') }} {% trans tcount=orgmemlist.extra_count, count=orgmemlist.extra_count|numberformat %}{{ count }} more{% pluralize tcount %}{{ count }} more{% endtrans %}{%- endif %} {%- endif %} diff --git a/funnel/templates/account_merge.html.jinja2 b/funnel/templates/account_merge.html.jinja2 index 3cd2053eb..15605b16e 100644 --- a/funnel/templates/account_merge.html.jinja2 +++ b/funnel/templates/account_merge.html.jinja2 @@ -4,26 +4,34 @@ {% macro accountinfo(user) %}
      -
    • {% trans %}Name:{% endtrans %} {{ user.fullname }}
    • -
    • {% trans %}Username:{% endtrans %} {% if user.username %}{{ user.username }}{% else %}{% trans %}(none){% endtrans %}{% endif %}
    • -
    • -
        - {%- for useremail in user.emails %} -
      • {% trans %}Email addresses:{% endtrans %} {{ useremail.email }}
      • - {%- else %} -
      • {% trans %}Email addresses:{% endtrans %} {% trans %}(none){% endtrans %}
      • - {%- endfor %} -
      -
    • -
    • {% trans %}Connected accounts:{% endtrans %} -
        - {%- for extid in user.externalids %} -
      • {{ login_registry[extid.service].title }}: {{ extid.username }}
      • - {%- else %} -
      • (none)
      • - {%- endfor %} -
      -
    • +
    • {% trans %}Name:{% endtrans %} {{ user.pickername }}
    • + {%- if user.emails %} +
    • {% trans %}Email addresses:{% endtrans %} +
        + {%- for accemail in user.emails %} +
      • {{ accemail.email }}
      • + {%- endfor %} +
      +
    • + {%- endif %} + {%- if user.phones %} +
    • {% trans %}Phone numbers:{% endtrans %} +
        + {%- for userphone in user.phones %} +
      • {{ userphone.formatted }}
      • + {%- endfor %} +
      +
    • + {%- endif %} + {%- if user.externalids %} +
    • {% trans %}Connected accounts:{% endtrans %} +
        + {%- for extid in user.externalids %} +
      • {% if extid.service in login_registry %}{{ login_registry[extid.service].title }}{% else %}{{ extid.service|capitalize }}{% endif %}: {{ extid.username }}
      • + {%- endfor %} +
      +
    • + {%- endif %}
    {% endmacro %} diff --git a/funnel/templates/account_organizations.html.jinja2 b/funnel/templates/account_organizations.html.jinja2 index e4d250536..65072ad0d 100644 --- a/funnel/templates/account_organizations.html.jinja2 +++ b/funnel/templates/account_organizations.html.jinja2 @@ -1,5 +1,5 @@ {% extends "layout.html.jinja2" %} -{%- from "macros.html.jinja2" import faicon, account_tabs %} +{%- from "macros.html.jinja2" import faicon, account_tabs, img_size %} {% block title %} {% trans %}Organizations{% endtrans %} {% endblock title %} @@ -30,25 +30,25 @@ {% for orgmem in current_auth.user.views.organizations_as_admin() %}
  • -

    - {{ orgmem.organization.profile.title }} - {% if not orgmem.organization.profile.state.PUBLIC %} + {{ orgmem.account.title }} + {% if not orgmem.account.profile_state.PUBLIC %} {{ faicon(icon='lock-alt', icon_size='caption', baseline=false, css_class="margin-left") }} {% endif %}

    @@ -61,7 +61,7 @@

    - {%- for user in orgmem.organization.admin_users %} + {%- for user in orgmem.account.admin_users %} {{ user.pickername }} {%- if not loop.last %},{% endif %} {%- endfor %}

    diff --git a/funnel/templates/account_saved.html.jinja2 b/funnel/templates/account_saved.html.jinja2 index 633fd3654..8a14ddf83 100644 --- a/funnel/templates/account_saved.html.jinja2 +++ b/funnel/templates/account_saved.html.jinja2 @@ -28,5 +28,5 @@ {% endblock basecontent %} {% block footerscripts %} - + {% endblock footerscripts %} diff --git a/funnel/templates/ajaxform.html.jinja2 b/funnel/templates/ajaxform.html.jinja2 index 65de82bce..ae84b8fe7 100644 --- a/funnel/templates/ajaxform.html.jinja2 +++ b/funnel/templates/ajaxform.html.jinja2 @@ -1,5 +1,5 @@
    - + {% from "forms.html.jinja2" import renderform, ajaxform, widget_ext_scripts, widgetscripts %} {%- from "macros.html.jinja2" import alertbox -%} {% block pageheaders %} @@ -20,27 +20,19 @@ {% block form %} {{ renderform(form=form, formid=formid, ref_id=ref_id, submit=submit, message=message, action=action, cancel_url=cancel_url, multipart=multipart, autosave=autosave, draft_revision=draft_revision) }} {% endblock form %} - {%- if with_chrome -%} - {{ ajaxform(ref_id=ref_id, request=request, force=true) }} - {%- endif -%} {{ widget_ext_scripts(form) }} {% block innerscripts %} - + - + {%- if with_chrome -%} + {{ ajaxform(ref_id=ref_id, request=request, force=true) }} + {%- endif -%} + {%- if form and 'recaptcha' in form %} + {% block recaptcha %}{% endblock recaptcha %} + {%- endif %} {% endblock innerscripts %}
    diff --git a/funnel/templates/auth_client.html.jinja2 b/funnel/templates/auth_client.html.jinja2 index 5e3a67a67..9e39e1079 100644 --- a/funnel/templates/auth_client.html.jinja2 +++ b/funnel/templates/auth_client.html.jinja2 @@ -17,11 +17,7 @@
  • {% trans %}Delete{% endtrans %}
  • {% trans %}New access key{% endtrans %}
  • - {%- if auth_client.user -%} - {% trans %}Assign permissions to a user{% endtrans %} - {%- else -%} - {% trans %}Assign permissions to a team{% endtrans %} - {%- endif -%} + {% trans %}Assign permissions to a user{% endtrans %}
  • @@ -30,7 +26,7 @@
    {% endif %} -
    +
    @@ -39,7 +35,7 @@
    {% trans %}Description{% endtrans %}
    {{ auth_client.description }}
    {% trans %}Owner{% endtrans %}
    -
    {{ auth_client.owner.pickername }}
    +
    {{ auth_client.account.pickername }}
    {% trans %}OAuth2 Type{% endtrans %}
    {% if auth_client.confidential %}{% trans %}Confidential{% endtrans %}{% else %}{% trans %}Public{% endtrans %}{% endif %}
    {% trans %}Website{% endtrans %}
    @@ -148,11 +144,7 @@ {% if auth_client.owner_is(current_auth.user) %}

    {% trans %}Permissions{% endtrans %}

    - {% if auth_client.user %} -

    {% trans %}The following users have permissions to this app{% endtrans %}

    - {% else %} -

    {% trans %}The following teams have permissions to this app{% endtrans %}

    - {% endif %} +

    {% trans %}The following users have permissions to this app{% endtrans %}

    {%- for pa in permassignments %} diff --git a/funnel/templates/auth_client_index.html.jinja2 b/funnel/templates/auth_client_index.html.jinja2 index 34947f764..d64ebcc8e 100644 --- a/funnel/templates/auth_client_index.html.jinja2 +++ b/funnel/templates/auth_client_index.html.jinja2 @@ -23,7 +23,7 @@ {{ loop.index }} {{ auth_client.title }} - {{ auth_client.owner.pickername }} + {{ auth_client.account.pickername }} {{ auth_client.website }} {% else %} diff --git a/funnel/templates/auth_dashboard.html.jinja2 b/funnel/templates/auth_dashboard.html.jinja2 index c89c0e02a..bdc2b7179 100644 --- a/funnel/templates/auth_dashboard.html.jinja2 +++ b/funnel/templates/auth_dashboard.html.jinja2 @@ -7,9 +7,9 @@ {% endblock pageheaders %} {% block content %} -

    {% trans active=mau %}{{ active }} monthly active users{% endtrans %}

    +

    {% trans active=mau|numberformat %}{{ active }} monthly active users{% endtrans %}

    -

    {% trans count=user_count %}{{ count }} total users{% endtrans %}

    +

    {% trans count=user_count|numberformat %}{{ count }} total users{% endtrans %}

    {% endblock content %} diff --git a/funnel/templates/badge.html.jinja2 b/funnel/templates/badge.html.jinja2 index 7a435c684..102ff0a14 100644 --- a/funnel/templates/badge.html.jinja2 +++ b/funnel/templates/badge.html.jinja2 @@ -1,8 +1,8 @@ - Badge - + {% trans %}Badge{% endtrans %} + + + + {# Outlook / @font-face : END #} + {% block stylesheet -%} - + {# CSS Reset : BEGIN #} + + {# CSS Reset : END #} + + {# Progressive Enhancements : BEGIN #} + + {# Progressive Enhancements : END #} {%- endblock stylesheet %} - - + + {# Element styles : BEGIN #} + + + + + {# + The email background color (#f0f0f0) is defined in three places: + 1. body tag: for most email clients + 2. center tag: for Gmail and Inbox mobile apps and web versions of Gmail, GSuite, Inbox, Yahoo, AOL, Libero, Comcast, freenet, Mail.ru, Orange.fr + 3. mso conditional: For Windows 10 Mail + #} + {%- block jsonld %}{%- if jsonld %} - + {%- endif %}{%- endblock jsonld %} -
    {% block content %}{% endblock content %}
    -
    {% block footer %} - {%- if view %} -
    -

    - {{ view.reason_email }} - • - {% trans %}Unsubscribe or manage preferences{% endtrans %} -

    - {%- endif %} - {% endblock footer %}
    - + +
    + + + {# + Set the email width. Defined in two places: + 1. max-width for all clients except Desktop Windows Outlook, allowing the email to squish on narrow but never go wider than 600px. + 2. MSO tags for Desktop Windows Outlook enforce a 600px width. + #} + + {# Email Footer : BEGIN #} + {% block footer %} + + + + {% endblock footer %}
    + {# Email Footer : END #} + +
    + diff --git a/funnel/templates/notifications/layout_web.html.jinja2 b/funnel/templates/notifications/layout_web.html.jinja2 index 9993052d8..f48f2d314 100644 --- a/funnel/templates/notifications/layout_web.html.jinja2 +++ b/funnel/templates/notifications/layout_web.html.jinja2 @@ -12,7 +12,7 @@ {%- endblock content %}
    {%- if not view.is_rollup %} -

    {{ view.user_notification.created_at|age }}

    +

    {{ view.notification_recipient.created_at|age }}

    {%- endif %}
    diff --git a/funnel/templates/notifications/macros_email.html.jinja2 b/funnel/templates/notifications/macros_email.html.jinja2 index 91e101c59..6245c1444 100644 --- a/funnel/templates/notifications/macros_email.html.jinja2 +++ b/funnel/templates/notifications/macros_email.html.jinja2 @@ -1,14 +1,68 @@ {%- macro pinned_update(view, project) -%} - {%- with update=project.pinned_update -%} - {%- if update -%} + {%- with update=project.pinned_update -%} + {%- if update -%} + + + + + + + + +

    + {%- trans number=update.number|numberformat -%}Update #{{ number }}{%- endtrans %} • {% trans age=update.published_at|age, editor=update.created_by.pickername -%}Posted by {{ editor }} {{ age }}{%- endtrans -%} +

    +

    {{ update.title }}

    + {{ update.body }} + + + {%- endif -%} + {%- endwith -%} +{%- endmacro -%} -

    - {%- trans number=update.number -%}Update #{{ number }}{%- endtrans %} • {% trans age=update.published_at|age, editor=update.user.pickername -%}Posted by {{ editor }} {{ age }}{%- endtrans -%} -

    -

    {{ update.title }}

    +{%- macro hero_image(img_url, alt_text) -%} + + + {{ alt_text }} + + +{%- endmacro -%} - {{ update.body }} +{% macro cta_button(btn_url, btn_text) %} + + +
    + + + + + + +
    + +
    + +
    + + +{% endmacro %} - {%- endif -%} - {%- endwith -%} -{%- endmacro -%} +{% macro rsvp_footer(view, rsvp_linktext) %} + {%- if view %} + + + + + + + + + {%- endif %} +{% endmacro %} diff --git a/funnel/templates/notifications/organization_membership_granted_email.html.jinja2 b/funnel/templates/notifications/organization_membership_granted_email.html.jinja2 index 7a4dfbe7f..4a1d764a2 100644 --- a/funnel/templates/notifications/organization_membership_granted_email.html.jinja2 +++ b/funnel/templates/notifications/organization_membership_granted_email.html.jinja2 @@ -1,10 +1,16 @@ {%- extends "notifications/layout_email.html.jinja2" -%} +{%- from "notifications/macros_email.html.jinja2" import cta_button -%} {%- block content -%} -

    {{ view.activity_html() }}

    -

    - - {%- trans %}See all admins{% endtrans -%} - -

    + + + +

    {{ view.activity_html() }}

    + + +
    + + {# Button : BEGIN #} + {{ cta_button(view.organization.url_for('members', _external=true, **view.tracking_tags()), gettext("See all admins") ) }} + {# Button : END #} + {%- endblock content -%} diff --git a/funnel/templates/notifications/organization_membership_granted_web.html.jinja2 b/funnel/templates/notifications/organization_membership_granted_web.html.jinja2 index 8d92210f9..00ae92c88 100644 --- a/funnel/templates/notifications/organization_membership_granted_web.html.jinja2 +++ b/funnel/templates/notifications/organization_membership_granted_web.html.jinja2 @@ -14,7 +14,7 @@ {%- endblock content -%} {%- block avatar %} {%- if view.is_rollup %} - {{ useravatar(view.membership.organization) }} + {{ useravatar(view.membership.account) }} {%- else %} {{ useravatar(view.actor) }} {%- endif %} diff --git a/funnel/templates/notifications/organization_membership_revoked_email.html.jinja2 b/funnel/templates/notifications/organization_membership_revoked_email.html.jinja2 index 7a4dfbe7f..4a1d764a2 100644 --- a/funnel/templates/notifications/organization_membership_revoked_email.html.jinja2 +++ b/funnel/templates/notifications/organization_membership_revoked_email.html.jinja2 @@ -1,10 +1,16 @@ {%- extends "notifications/layout_email.html.jinja2" -%} +{%- from "notifications/macros_email.html.jinja2" import cta_button -%} {%- block content -%} -

    {{ view.activity_html() }}

    -

    - - {%- trans %}See all admins{% endtrans -%} - -

    + + + +

    {{ view.activity_html() }}

    + + +
    + + {# Button : BEGIN #} + {{ cta_button(view.organization.url_for('members', _external=true, **view.tracking_tags()), gettext("See all admins") ) }} + {# Button : END #} + {%- endblock content -%} diff --git a/funnel/templates/notifications/organization_membership_revoked_web.html.jinja2 b/funnel/templates/notifications/organization_membership_revoked_web.html.jinja2 index 9eff7387f..090108aaa 100644 --- a/funnel/templates/notifications/organization_membership_revoked_web.html.jinja2 +++ b/funnel/templates/notifications/organization_membership_revoked_web.html.jinja2 @@ -14,7 +14,7 @@ {%- endblock content -%} {%- block avatar %} {%- if view.is_rollup %} - {{ useravatar(view.membership.organization) }} + {{ useravatar(view.membership.account) }} {%- else %} {{ useravatar(view.actor) }} {%- endif %} diff --git a/funnel/templates/notifications/project_crew_membership_granted_email.html.jinja2 b/funnel/templates/notifications/project_crew_membership_granted_email.html.jinja2 index f48dbe234..5ebccc8be 100644 --- a/funnel/templates/notifications/project_crew_membership_granted_email.html.jinja2 +++ b/funnel/templates/notifications/project_crew_membership_granted_email.html.jinja2 @@ -1,10 +1,16 @@ {%- extends "notifications/layout_email.html.jinja2" -%} +{%- from "notifications/macros_email.html.jinja2" import cta_button -%} {%- block content -%} -

    {{ view.activity_html() }}

    -

    - - {%- trans %}See all crew members{% endtrans -%} - -

    + + + +

    {{ view.activity_html() }}

    + + +
    + + {# Button : BEGIN #} + {{ cta_button(view.project.url_for('crew', _external=true, **view.tracking_tags()), gettext("See all crew members") )}} + {# Button : END #} + {%- endblock content -%} diff --git a/funnel/templates/notifications/project_crew_membership_granted_web.html.jinja2 b/funnel/templates/notifications/project_crew_membership_granted_web.html.jinja2 index f93605830..cb2785fa9 100644 --- a/funnel/templates/notifications/project_crew_membership_granted_web.html.jinja2 +++ b/funnel/templates/notifications/project_crew_membership_granted_web.html.jinja2 @@ -18,7 +18,7 @@ {%- block avatar -%} {%- if view.is_rollup -%} - {{ useravatar(view.membership.project.profile) }} + {{ useravatar(view.membership.project.account) }} {%- else -%} {{ useravatar(view.actor) }} {%- endif -%} diff --git a/funnel/templates/notifications/project_crew_membership_revoked_email.html.jinja2 b/funnel/templates/notifications/project_crew_membership_revoked_email.html.jinja2 index 364066eb7..925c7468c 100644 --- a/funnel/templates/notifications/project_crew_membership_revoked_email.html.jinja2 +++ b/funnel/templates/notifications/project_crew_membership_revoked_email.html.jinja2 @@ -1,10 +1,16 @@ {%- extends "notifications/layout_email.html.jinja2" -%} +{%- from "notifications/macros_email.html.jinja2" import cta_button -%} {%- block content -%} -

    {{ view.activity_html() }}

    -

    - - {%- trans %}See all admins{% endtrans -%} - -

    + + + +

    {{ view.activity_html() }}

    + + +
    + + {# Button : BEGIN #} + {{ cta_button(view.project.account.url_for('members', _external=true, **view.tracking_tags()), gettext("See all crew members") ) }} + {# Button : END #} + {%- endblock content -%} diff --git a/funnel/templates/notifications/project_crew_membership_revoked_web.html.jinja2 b/funnel/templates/notifications/project_crew_membership_revoked_web.html.jinja2 index e3073698d..0ad33a10b 100644 --- a/funnel/templates/notifications/project_crew_membership_revoked_web.html.jinja2 +++ b/funnel/templates/notifications/project_crew_membership_revoked_web.html.jinja2 @@ -14,7 +14,7 @@ {%- endblock content -%} {%- block avatar %} {%- if view.is_rollup %} - {{ useravatar(view.membership.project.profile) }} + {{ useravatar(view.membership.project.account) }} {%- else %} {{ useravatar(view.actor) }} {%- endif %} diff --git a/funnel/templates/notifications/project_starting_email.html.jinja2 b/funnel/templates/notifications/project_starting_email.html.jinja2 index b433d151d..0d3bb507d 100644 --- a/funnel/templates/notifications/project_starting_email.html.jinja2 +++ b/funnel/templates/notifications/project_starting_email.html.jinja2 @@ -1,15 +1,18 @@ {%- extends "notifications/layout_email.html.jinja2" -%} -{%- from "notifications/macros_email.html.jinja2" import pinned_update -%} +{%- from "notifications/macros_email.html.jinja2" import pinned_update, cta_button, rsvp_footer -%} {%- block content -%} -

    - {%- trans project=view.project.joined_title, start_time=view.session.start_at_localized|time -%} - {{ project }} starts at {{ start_time }} - {%- endtrans -%} -

    + + +

    {%- trans project=view.project.joined_title, start_time=(view.session or view.project).start_at_localized|time -%}{{ project }} starts at {{ start_time }}{%- endtrans -%}

    + + +
    -

    {% trans %}Join now{% endtrans %}

    + {# Button : BEGIN #} + {{ cta_button(view.project.url_for(_external=true, **view.tracking_tags()), gettext("Join now") )}} + {# Button : END #} -{{ pinned_update(view, view.project) }} + {{ pinned_update(view, view.project) }} {%- endblock content -%} diff --git a/funnel/templates/notifications/project_starting_web.html.jinja2 b/funnel/templates/notifications/project_starting_web.html.jinja2 index 63389859e..c197a74d1 100644 --- a/funnel/templates/notifications/project_starting_web.html.jinja2 +++ b/funnel/templates/notifications/project_starting_web.html.jinja2 @@ -2,12 +2,12 @@ {%- from "macros.html.jinja2" import useravatar %} {%- block avatar %} - {{ useravatar(view.project.profile.owner) }} + {{ useravatar(view.project.account) }} {%- endblock avatar -%} {%- block content -%}

    - {% trans project=view.project.joined_title, url=view.project.url_for(), start_time=view.session.start_at_localized|time -%} + {% trans project=view.project.joined_title, url=view.project.url_for(), start_time=(view.session or view.project).start_at_localized|time -%} {{ project }} starts at {{ start_time }} {%- endtrans %}

    diff --git a/funnel/templates/notifications/proposal_received_email.html.jinja2 b/funnel/templates/notifications/proposal_received_email.html.jinja2 index 49d40d6ad..97aada475 100644 --- a/funnel/templates/notifications/proposal_received_email.html.jinja2 +++ b/funnel/templates/notifications/proposal_received_email.html.jinja2 @@ -1,8 +1,16 @@ -{% extends "notifications/layout_email.html.jinja2" %} -{% block content -%} +{%- extends "notifications/layout_email.html.jinja2" -%} +{%- from "notifications/macros_email.html.jinja2" import cta_button -%} +{%- block content -%} -

    {% trans project=project.joined_title, proposal=proposal.title %}Your project {{ project }} has a new submission: {{ proposal }}{% endtrans %}

    + + +

    {% trans project=project.joined_title, proposal=proposal.title %}Your project {{ project }} has a new submission: {{ proposal }}{% endtrans %}

    + + +
    -

    {% trans %}Submission page{% endtrans %}

    + {# Button : BEGIN #} + {{ cta_button(proposal.url_for(_external=true, **view.tracking_tags()), gettext("Submission page") )}} + {# Button : END #} -{%- endblock content %} +{%- endblock content -%} diff --git a/funnel/templates/notifications/proposal_received_web.html.jinja2 b/funnel/templates/notifications/proposal_received_web.html.jinja2 index 0bb2221e7..a7932a686 100644 --- a/funnel/templates/notifications/proposal_received_web.html.jinja2 +++ b/funnel/templates/notifications/proposal_received_web.html.jinja2 @@ -4,13 +4,13 @@ {% block content %} {%- if view.is_rollup %}

    - {%- trans project=project.joined_title, project_url=project.url_for(), count=view.fragments|length %} + {%- trans project=project.joined_title, project_url=project.url_for(), count=view.fragments|length|numberformat %} Your project {{ project }} has received {{ count }} new submissions: {%- endtrans %}

      {%- for proposal in view.fragments %} -
    1. {% trans url=proposal.url_for(), proposal=proposal.title, actor=proposal.user.pickername, age=proposal.datetime|age %} +
    2. {% trans url=proposal.url_for(), proposal=proposal.title, actor=proposal.first_user.pickername, age=proposal.datetime|age %} {{ proposal }} by {{ actor }} {{ age }} {% endtrans %}
    3. diff --git a/funnel/templates/notifications/proposal_submitted_email.html.jinja2 b/funnel/templates/notifications/proposal_submitted_email.html.jinja2 index b856dc0e6..d34e25c4d 100644 --- a/funnel/templates/notifications/proposal_submitted_email.html.jinja2 +++ b/funnel/templates/notifications/proposal_submitted_email.html.jinja2 @@ -1,8 +1,16 @@ -{% extends "notifications/layout_email.html.jinja2" %} -{% block content -%} +{%- extends "notifications/layout_email.html.jinja2" -%} +{%- from "notifications/macros_email.html.jinja2" import cta_button -%} +{%- block content -%} -

      {% trans project=project.joined_title, proposal=proposal.title %}You have submitted {{ proposal }} to the project {{ project }}{% endtrans %}

      + + +

      {% trans project=project.joined_title, proposal=proposal.title %}You have submitted {{ proposal }} to the project {{ project }}{% endtrans %}

      + + +
      -

      {% trans %}View submission{% endtrans %}

      + {# Button : BEGIN #} + {{ cta_button(proposal.url_for(_external=true, **view.tracking_tags()), gettext("View submission") )}} + {# Button : END #} -{%- endblock content %} +{%- endblock content -%} diff --git a/funnel/templates/notifications/rsvp_no_email.html.jinja2 b/funnel/templates/notifications/rsvp_no_email.html.jinja2 index 967f10861..db1a82d0c 100644 --- a/funnel/templates/notifications/rsvp_no_email.html.jinja2 +++ b/funnel/templates/notifications/rsvp_no_email.html.jinja2 @@ -1,8 +1,19 @@ {% extends "notifications/layout_email.html.jinja2" %} +{%- from "notifications/macros_email.html.jinja2" import cta_button, rsvp_footer -%} {% block content -%} -

      {% trans project=view.rsvp.project.joined_title %}You have cancelled your registration for {{ project }}. If this was accidental, you can register again.{% endtrans %}

      + + +

      {% trans project=view.rsvp.project.joined_title %}You have cancelled your registration for {{ project }}{% endtrans %}

      + + -

      {% trans %}Project page{% endtrans %}

      + {# Button : BEGIN #} + {{ cta_button(view.rsvp.project.url_for(_external=true, **view.tracking_tags()), view.rsvp.project.joined_title )}} + {# Button : END #} + + {# Email body footer : BEGIN #} + {{ rsvp_footer(view, gettext("Register again")) }} + {# Email body footer : END #} {%- endblock content %} diff --git a/funnel/templates/notifications/rsvp_yes_email.html.jinja2 b/funnel/templates/notifications/rsvp_yes_email.html.jinja2 index 9528aec75..3dcb6ff13 100644 --- a/funnel/templates/notifications/rsvp_yes_email.html.jinja2 +++ b/funnel/templates/notifications/rsvp_yes_email.html.jinja2 @@ -1,15 +1,24 @@ {% extends "notifications/layout_email.html.jinja2" -%} -{%- from "notifications/macros_email.html.jinja2" import pinned_update -%} +{%- from "notifications/macros_email.html.jinja2" import pinned_update, cta_button, rsvp_footer -%} {%- block content -%} -

      {% trans project=view.rsvp.project.joined_title %}You have registered for {{ project }}{% endtrans %}

      + + +

      {% trans project=view.rsvp.project.joined_title %}You have registered for {{ project }}{% endtrans %}

      + {% with next_session_at=view.rsvp.project.next_session_at %}{% if next_session_at -%} +

      {% trans date_and_time=next_session_at|datetime(view.datetime_format) %}The next session in the schedule starts {{ date_and_time }}{% endtrans %}


      + {%- endif %}{% endwith %} + + -{% with next_session_at=view.rsvp.project.next_session_at %}{% if next_session_at -%} -

      {% trans date_and_time=next_session_at|datetime(view.datetime_format) %}The next session in the schedule starts {{ date_and_time }}{% endtrans %}

      -{%- endif %}{% endwith %} + {# Button : BEGIN #} + {{ cta_button(view.rsvp.project.url_for(_external=true, **view.tracking_tags()), view.rsvp.project.joined_title )}} + {# Button : END #} -

      {% trans %}Project page{% endtrans %}

      + {{ pinned_update(view, view.rsvp.project) }} -{{ pinned_update(view, view.rsvp.project) }} + {# Email body footer : BEGIN #} + {{ rsvp_footer(view, gettext("Cancel registration")) }} + {# Email body footer : END #} {%- endblock content -%} diff --git a/funnel/templates/notifications/update_new_email.html.jinja2 b/funnel/templates/notifications/update_new_email.html.jinja2 index 8a5a2a3a1..b33cd1e91 100644 --- a/funnel/templates/notifications/update_new_email.html.jinja2 +++ b/funnel/templates/notifications/update_new_email.html.jinja2 @@ -1,12 +1,26 @@ -{% extends "notifications/layout_email.html.jinja2" %} -{% block content %} +{%- extends "notifications/layout_email.html.jinja2" -%} +{%- from "notifications/macros_email.html.jinja2" import cta_button -%} +{%- block content -%} -

      {% trans actor=view.actor.pickername, project=view.update.project.joined_title, project_url=view.update.project.url_for() %}{{ actor }} posted an update in {{ project }}:{% endtrans %}

      + + +

      {% trans actor=view.actor.pickername, project=view.update.project.joined_title, project_url=view.update.project.url_for(_external=true, **view.tracking_tags()) %}{{ actor }} posted an update in {{ project }}:{% endtrans %}

      + + + + + +
      + + +

      {% trans update_title=view.update.title %}{{ update_title }}{% endtrans %}

      + {% trans update_body=view.update.body %}{{ update_body }}{% endtrans %} + + +
      -

      {{ view.update.title }}

      + {# Button : BEGIN #} + {{ cta_button(view.update.url_for(_external=true, **view.tracking_tags()), gettext("Read on the website") )}} + {# Button : END #} -{{ view.update.body }} - -

      {% trans %}Read on the website{% endtrans %}

      - -{% endblock content %} +{%- endblock content -%} diff --git a/funnel/templates/notifications/user_password_set_email.html.jinja2 b/funnel/templates/notifications/user_password_set_email.html.jinja2 index e8e01b380..73c9eb527 100644 --- a/funnel/templates/notifications/user_password_set_email.html.jinja2 +++ b/funnel/templates/notifications/user_password_set_email.html.jinja2 @@ -1,17 +1,17 @@ -{%- extends "notifications/layout_email.html.jinja2" %} - +{%- extends "notifications/layout_email.html.jinja2" -%} +{%- from "notifications/macros_email.html.jinja2" import cta_button -%} {%- block content -%} -

      - {%- trans %}Your password has been updated. If you did this, no further action is necessary.{% endtrans -%} -

      -

      - {%- trans %}If this was not authorized, consider resetting with a more secure password. Contact support if further assistance is required.{% endtrans -%} -

      + + + {% if view.email_heading %}

      {{ view.email_heading }}

      {% endif %} +

      {%- trans support_email=config['SITE_SUPPORT_EMAIL'] %}Your password has been updated. If you did this, no further action is necessary, but if this was not authorized, consider resetting with a more secure password. Contact support if further assistance is required.{% endtrans -%}

      + + +
      -

      - {% trans %}Reset password{% endtrans %} - {% trans %}Contact support{% endtrans %} -

      + {# Button : BEGIN #} + {{ cta_button(url_for('reset'), gettext("Reset password") )}} + {# Button : END #} {%- endblock content -%} diff --git a/funnel/templates/oauth_authorize.html.jinja2 b/funnel/templates/oauth_authorize.html.jinja2 index 644d51649..ff64cbfa2 100644 --- a/funnel/templates/oauth_authorize.html.jinja2 +++ b/funnel/templates/oauth_authorize.html.jinja2 @@ -16,7 +16,7 @@ {% trans %}Owner{% endtrans %} - {{ auth_client.owner.pickername }} + {{ auth_client.account.pickername }} {% trans %}Website{% endtrans %} diff --git a/funnel/templates/opensearch.xml.jinja2 b/funnel/templates/opensearch.xml.jinja2 index f0f4c23c8..be97f2057 100644 --- a/funnel/templates/opensearch.xml.jinja2 +++ b/funnel/templates/opensearch.xml.jinja2 @@ -3,8 +3,8 @@ {% trans %}Hasgeek{% endtrans %} {% trans %}Search Hasgeek for projects, discussions and more{% endtrans %} - Hasgeek + template="{{ url_for('search', _external=true) }}?q={searchTerms}"/> + {% trans %}Hasgeek{% endtrans %} {{ get_locale() }} UTF-8 UTF-8 diff --git a/funnel/templates/organization_membership.html.jinja2 b/funnel/templates/organization_membership.html.jinja2 index 64b04bca2..f166d312a 100644 --- a/funnel/templates/organization_membership.html.jinja2 +++ b/funnel/templates/organization_membership.html.jinja2 @@ -1,19 +1,16 @@ -{% extends "layout.html.jinja2" %} -{% block title %}{{ profile.title }}{% endblock title %} -{%- from "macros.html.jinja2" import faicon, profile_header %} +{% extends "profile_layout.html.jinja2" %} +{%- from "macros.html.jinja2" import faicon %} {%- from "js/membership.js.jinja2" import membership_template, profile_member_template %} {% block pageheaders %} - - + + {% endblock pageheaders %} {% block bodyattrs %}class="bg-primary no-sticky-header mobile-header"{% endblock bodyattrs %} -{% block contenthead %}{% endblock contenthead %} - {% block baseheadline %} - {{ profile_header(profile, class="mui--hidden-xs mui--hidden-sm", current_page="admins", title=gettext("Admins")) }} + {{ profile_header(account, class="mui--hidden-xs mui--hidden-sm", current_page="admins", title=_("Admins")) }} {% endblock baseheadline %} {% block basecontent %} @@ -32,12 +29,12 @@ {% endblock basecontent %} {% block footerscripts %} - + +{% block innerscripts %} + -{% endblock footerscripts %} +{% endblock innerscripts %} diff --git a/funnel/templates/profile_layout.html.jinja2 b/funnel/templates/profile_layout.html.jinja2 new file mode 100644 index 000000000..4df0fee3b --- /dev/null +++ b/funnel/templates/profile_layout.html.jinja2 @@ -0,0 +1,480 @@ +{% extends "layout.html.jinja2" %} +{%- from "macros.html.jinja2" import img_size, saveprojectform, calendarwidget, projectcard, video_thumbnail %} +{%- from "js/schedule.js.jinja2" import schedule_template %} +{% block title %}{{ profile.title }}{% endblock title %} + +{% macro featured_section(featured_project, heading=true) %} + {% if featured_project %} +
      + {% with current_sessions = featured_project.current_sessions() if + featured_project is not none else none %} + {% if current_sessions and current_sessions.sessions|length > 0 %} +
      +
      +
      +
      +

      + {% if not featured_project.livestream_urls and current_sessions.sessions|length > 0 %} + {% trans %}Live schedule{% endtrans %} + {% elif featured_project.livestream_urls and not current_sessions.sessions|length > 0 %} + {% trans %}Livestream{% endtrans %} + {% elif featured_project.livestream_urls and current_sessions.sessions|length > 0 %} + {% trans %}Livestream and schedule{% endtrans %} + {% endif %} +

      +
      +
      +
      +
      + {% if featured_project.bg_image.url %} + {{ featured_project.title }} + {% else %} + {{ featured_project.title }} +

      {{ featured_project.title }}

      + {% endif %} +
      +
      +

      + {{ featured_project.title }} +

      +

      {% trans %}Live{% endtrans %}

      + {% if current_sessions.sessions|length > 0 %} +

      {{ faicon(icon='clock') }} {% trans session=current_sessions.sessions[0].start_at_localized|time %}Session starts at {{ session }}{% endtrans %}

      + {% endif %} +
      + {%- if featured_project.livestream_urls %} + {% trans %}Livestream{% endtrans %} + {%- endif %} + {%- if current_sessions.sessions|length > 0 %} + {% trans %}Schedule{% endtrans %} + {%- endif %} +
      +
      +
      +
      +
      +
      +
      + {% endif %} + {% endwith %} + +
      +
      + {% if heading %} +
      +
      +
      +

      {% trans %}Spotlight{% endtrans %}

      +
      +
      +
      + {% endif %} + +
      +
      +
      +
      + + {%- if not current_auth.is_anonymous %} + {% set save_form_id = "spotlight_spfm_desktop_" + featured_project.uuid_b58 %} +
      {{ saveprojectform(featured_project, formid=save_form_id) }}
      + {% endif %} +
      +

      {{ featured_project.title }}

      +

      {{ featured_project.tagline }}

      +
      {% if featured_project.primary_venue %}{{ faicon(icon='map-marker-alt', icon_size='caption', baseline=false) }} {% if featured_project.primary_venue.city %}{{ featured_project.primary_venue.city }}{% else %}{{ featured_project.primary_venue.title }}{% endif %}{% elif featured_project.location %}{{ faicon(icon='map-marker-alt', icon_size='caption', baseline=false) }} {{ featured_project.location }}{% endif %}
      + {% trans %}Learn more{% endtrans %} +
      +
      +
      +
      +
      +
      +
      + + {%- if featured_project.account.logo_url.url %} + {{ featured_project.account.title }} + {% else %} + {{ featured_project.account.title }} + {% endif %} + + {{ featured_project.account.title }} +
      + {%- if not current_auth.is_anonymous %} + {% set save_form_id = "spotlight_spfm_mobile_" + featured_project.uuid_b58 %} +
      {{ saveprojectform(featured_project, formid=save_form_id) }}
      + {% endif %} +
      + + {%- if (featured_project.start_at is not none and featured_project.calendar_weeks_full.weeks and featured_project.calendar_weeks_full.weeks|length > 0) %} +
      + {% if calendarwidget_compact and featured_project.start_at and featured_project.calendar_weeks_compact.weeks and featured_project.calendar_weeks_compact.weeks|length > 0 %} +
      + {{ calendarwidget(featured_project.calendar_weeks_compact) }} +
      + {% elif featured_project.start_at and featured_project.calendar_weeks_full.weeks and featured_project.calendar_weeks_full.weeks|length > 0 %} +
      + {{ calendarwidget(featured_project.calendar_weeks_full, compact=false) }} +
      + {% endif %} + +
      +
      {% if featured_project.primary_venue %}{{ faicon(icon='map-marker-alt', icon_size='caption', baseline=false) }} {% if featured_project.primary_venue.city %}{{ featured_project.primary_venue.city }}{% else %}{{ featured_project.primary_venue.title }}{% endif %}{% elif featured_project.location %}{{ faicon(icon='map-marker-alt', icon_size='caption', baseline=false) }} {{ featured_project.location }}{% endif %}
      +
      +
      + {% endif %} +
      +
      + {% if featured_project and featured_project.schedule_start_at -%} +
      +
      +
      + +
      + {{ schedule_template() }} +
      +
      + {% endif %} +
      +
      +
      +
      +
      + {% endif %} +{% endmacro %} + +{% macro upcoming_section(upcoming_projects, heading=true) %} + {% if upcoming_projects|length > 0 %} +
      +
      + {% if heading %} +
      +
      +

      {% trans %}Upcoming{% endtrans %}

      +
      +
      + {% endif %} +
        + {% for project in upcoming_projects %} +
      • + {{ projectcard(project, save_form_id_prefix='upcoming_spf_') }} +
      • + {%- endfor -%} +
      +
      +
      + {% endif %} +{% endmacro %} + +{% macro open_cfp_section(open_cfp_projects, heading=true) %} + {% if open_cfp_projects %} +
      +
      + {% if heading %} +
      +
      +

      {% trans %}Accepting submissions{% endtrans %}

      +
      +
      + {% endif %} +
        + {% for project in open_cfp_projects %} +
      • + {{ projectcard(project, include_calendar=false, save_form_id_prefix='open_spf_') }} +
      • + {%- endfor -%} +
      + {% if open_cfp_projects|length > 4 %} + + {% endif %} +
      +
      + {% endif %} +{% endmacro %} + +{% macro all_projects_section(all_projects, heading=true) %} + {% if all_projects %} +
      +
      + {% if heading %} +
      +
      +

      {% trans %}All projects{% endtrans %}

      +
      +
      + {% endif %} +
        + {% for project in all_projects %} +
      • + {{ projectcard(project, save_form_id_prefix='all_spf_') }} +
      • + {%- endfor -%} +
      +
      +
      + {% endif %} +{% endmacro %} + +{% macro profile_admin_buttons(profile) %} + + +{% endmacro %} + +{% macro past_projects_section(profile) %} +
      +
      +
      +
      +

      {% trans %}Past sessions{% endtrans %}

      +
      +
      + + + + + + + + + + + +
      {% trans %}Date{% endtrans %}{% trans %}Project{% endtrans %}{% trans %}Location{% endtrans %}
      +
      +
      +
      +
      +{% endmacro %} + +{% macro past_featured_session_videos(profile) %} +
      +
      +
      +
      +

      {% trans %}Past videos{% endtrans %}

      +
      +
        +
      • +
        +
        +

        {{ faicon(icon='play', icon_size='headline', baseline=false) }}

        +

        Loading

        +
        +
        +
      • +
      +
      +
      +
      +{% endmacro %} + +{% macro profile_header(profile, class="", current_page='profile', title="") %} +
      +
      + {{ faicon(icon='arrow-left', icon_size='title') }}{%- if title %}{{ title }}{% endif %} + {% if current_page == 'profile' and profile.current_roles.admin %} +
      {{ profile_admin_buttons(profile) }}
      + {%- endif %} +
      +
      +
      +
      +
      +
      + {%- if profile.banner_image_url.url %} + {{ profile.title }} + {% else %} + {{ profile.title }} + {% endif %} +
      +
      +
      +
      + {%- if profile.current_roles.admin %} + {{ faicon(icon='camera', icon_size='body2', css_class="profile__banner__icon") }} {% trans %}Add cover photo{% endtrans %} + {% endif %} + +
      + {% if profile.features.new_project() %} + {{ faicon(icon='plus', icon_size='caption') }} {% trans %}New project{% endtrans %} + {% elif profile.features.make_public() %} + {% trans %}Make account public{% endtrans %} + + {% endif %} + {%- if profile.current_roles.admin %} +
      {{ profile_admin_buttons(profile) }}
      + {% endif %} +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +

      {{ profile.title }}

      +

      @{{ profile.name }}

      +
      + +
      {{ profile.description }}
      +
      + {% if profile.features.new_project() %} + + {% elif profile.features.make_public() %} + + {% endif %} +
      +
      +
      +
      +
      + +{% endmacro %} + +{% block bodyattrs %}class="bg-primary mobile-header"{% endblock bodyattrs %} + +{% block contenthead %} +{% endblock contenthead %} + +{% block baseheadline %} + {{ profile_header(profile) }} +{% endblock baseheadline %} + +{% block basecontent %} +{% endblock basecontent %} + + +{% block footerscripts %} + {% block innerscripts %}{% endblock innerscripts %} + {% if featured_project and featured_project.schedule_start_at -%} + + + {%- endif %} +{% endblock footerscripts %} diff --git a/funnel/templates/project.html.jinja2 b/funnel/templates/project.html.jinja2 index f28633f7b..7ac49ee95 100644 --- a/funnel/templates/project.html.jinja2 +++ b/funnel/templates/project.html.jinja2 @@ -5,7 +5,7 @@ {% extends "project_spa_layout.html.jinja2" %} {%- from "project_layout.html.jinja2" import pinned_update, project_about with context %} {% endif %} -{% block titleblock %}{{ project.title }}{%- endblock titleblock %} +{% block titleblock %}{% block title %}{{ project.title }}{%- endblock title %}{%- endblock titleblock %} {%- from "macros.html.jinja2" import proposal_card, video_thumbnail %} {%- block pageheaders %} @@ -48,8 +48,8 @@ "description": {{ project.tagline|tojson }}, "organizer": { "@type": "Organization", - "name": {{ project.profile.title|tojson }}, - "url": {{ project.profile.url_for(_external=true)|tojson }} + "name": {{ project.account.title|tojson }}, + "url": {{ project.account.url_for(_external=true)|tojson }} } } @@ -182,7 +182,7 @@ - + - + + + {% block footerinnerscripts %}{% endblock footerinnerscripts %} diff --git a/funnel/templates/project_membership.html.jinja2 b/funnel/templates/project_membership.html.jinja2 index 1f4e7965e..0c3750555 100644 --- a/funnel/templates/project_membership.html.jinja2 +++ b/funnel/templates/project_membership.html.jinja2 @@ -9,7 +9,7 @@ {% block title %}{% trans %}Crew{% endtrans %}{% endblock title %} {% block pageheaders %} - + {% endblock pageheaders %} {% block left_col %} @@ -22,7 +22,7 @@ {% endblock left_col %} {% block footerinnerscripts %} - + + {% endblock footerinnerscripts %} diff --git a/funnel/templates/project_schedule.html.jinja2 b/funnel/templates/project_schedule.html.jinja2 index 0513222e9..efc9113ba 100644 --- a/funnel/templates/project_schedule.html.jinja2 +++ b/funnel/templates/project_schedule.html.jinja2 @@ -35,7 +35,7 @@ {%- block pageheaders -%} + href="{{ manifest('css/schedule.css') }}"/> {%- if project.start_at %} @@ -108,7 +108,7 @@
    {%- endblock left_col -%} {%- block footerinnerscripts -%} - + +{{ ajaxform(ref_id=ref_id, request=request, force=true) }} diff --git a/funnel/templates/project_submissions.html.jinja2 b/funnel/templates/project_submissions.html.jinja2 index df04bbe30..f79aad387 100644 --- a/funnel/templates/project_submissions.html.jinja2 +++ b/funnel/templates/project_submissions.html.jinja2 @@ -8,7 +8,7 @@ {% block title %}{% trans %}Submissions{% endtrans %}{% endblock title %} {% block pageheaders %} - + {% endblock pageheaders %} {% block left_col %} @@ -45,7 +45,7 @@
    @@ -57,7 +57,7 @@ {% endblock left_col %} {% block footerinnerscripts %} - + + + diff --git a/funnel/templates/scan_badge.html.jinja2 b/funnel/templates/scan_badge.html.jinja2 index a01a22966..54c8f9241 100644 --- a/funnel/templates/scan_badge.html.jinja2 +++ b/funnel/templates/scan_badge.html.jinja2 @@ -4,7 +4,7 @@ {% block title %}{{ ticket_event.title }}{% endblock title %} {% block pageheaders %} - + {% endblock pageheaders %} {% block bodyattrs %}class="bg-primary no-header"{% endblock bodyattrs %} @@ -17,11 +17,11 @@ {% endblock basecontent %} {% block footerscripts %} - + + + + + {{ ajaxform(ref_id=ref_id, request=request) }} {%- endif %} diff --git a/funnel/templates/session_view_popup.html.jinja2 b/funnel/templates/session_view_popup.html.jinja2 index 8fdca11a9..807cd25aa 100644 --- a/funnel/templates/session_view_popup.html.jinja2 +++ b/funnel/templates/session_view_popup.html.jinja2 @@ -21,7 +21,7 @@ {% endif %}
    @@ -30,16 +30,29 @@ diff --git a/funnel/templates/siteadmin_generate_shortlinks.html.jinja2 b/funnel/templates/siteadmin_generate_shortlinks.html.jinja2 new file mode 100644 index 000000000..27a4c157f --- /dev/null +++ b/funnel/templates/siteadmin_generate_shortlinks.html.jinja2 @@ -0,0 +1,36 @@ +{% extends "layout.html.jinja2" %} + +{% block title %}{% trans %}Get shortlink with additional tags{% endtrans %}{% endblock title %} + +{% block pageheaders %} + +{% endblock pageheaders %} + +{% block content %} +
    +
    +
    +
    + + +
    +
    +
    + + + {% trans %}Shorten link{% endtrans %} + +
    +{% endblock content %} + +{% block footerscripts %} + +{% endblock footerscripts %} diff --git a/funnel/templates/siteadmin_layout.html.jinja2 b/funnel/templates/siteadmin_layout.html.jinja2 index d27fb90c1..aab3cf634 100644 --- a/funnel/templates/siteadmin_layout.html.jinja2 +++ b/funnel/templates/siteadmin_layout.html.jinja2 @@ -2,7 +2,7 @@ {% from "forms.html.jinja2" import renderform, rendersubmit %} {% block top_title %} -

    Site admin

    +

    {% trans %}Site admin{% endtrans %}

    {% endblock top_title %} {% block contentwrapper -%} diff --git a/funnel/templates/submission.html.jinja2 b/funnel/templates/submission.html.jinja2 index 3c54f431e..a097184f8 100644 --- a/funnel/templates/submission.html.jinja2 +++ b/funnel/templates/submission.html.jinja2 @@ -15,10 +15,10 @@ {%- block pageheaders -%} + href="{{ manifest('css/submission.css') }}"/> + href="{{ manifest('css/comments.css') }}"/> {%- endblock pageheaders -%} {%- block bodyattrs -%} class="bg-accent no-sticky-header mobile-header proposal-page subproject-page {% if proposal.views.video or proposal.session and proposal.session.views.video %}mobile-hide-livestream{% endif %}" @@ -29,9 +29,8 @@ class="hg-link-btn mui--hide mui--text-dark" data-ga="Share dropdown" data-cy="share-project" - data-url="{{ proposal.url_for(_external=true, utm_campaign='webshare') }}"> - {{ faicon(icon='share-alt', icon_size=icon_size, baseline=false, css_class="mui--align-middle") }} - + data-url="{{ proposal.url_for(_external=true, utm_source='webshare') }}" + aria-label="{% trans %}Share{% endtrans %}">{{ faicon(icon='share-alt', icon_size=icon_size, baseline=false, css_class="mui--align-middle") }}
    + data-cy="subscribe-proposal" + aria-label=" + {%- if subscribed -%} + {% trans %}Unsubscribe{% endtrans %} + {%- else -%} + {% trans %}Subscribe{% endtrans %} + {%- endif -%}"> {%- if subscribed -%} {{ faicon(icon='bell', icon_size='title', baseline=false, css_class="mui--align-middle fa-icon--left-margin js-subscribed mui--hide") }} {{ faicon(icon='bell-solid', icon_size='title', baseline=false, css_class="mui--align-middle fa-icon--left-margin js-unsubscribed") }} @@ -114,8 +119,7 @@
  • {{ faicon(icon='video-plus', icon_size='subhead', baseline=false, css_class="mui--text-light fa-icon--right-margin mui--align-middle") }} + data-cy="edit-proposal-video">{{ faicon(icon='video-plus', icon_size='subhead', baseline=false, css_class="mui--text-light fa-icon--right-margin mui--align-middle") }} {%- if proposal.views.video -%} {% trans %}Replace video{% endtrans %} {%- else -%} @@ -131,6 +135,11 @@
  • {%- endif %} {%- if proposal.current_roles.project_editor %} +
  • + {{ faicon(icon='eye', icon_size='subhead', baseline=false, css_class="mui--text-light fa-icon--right-margin mui--align-middle") }}{% trans %}View contact details{% endtrans %} +
  • + {% with seq = 1 %} {% if proposal.session and proposal.session.views.video or proposal.views.video or proposal.current_roles.editor %}
    - {% with seq = 1 %} - {% if proposal.session and proposal.session.views.video and proposal.session.views.video.url %} -
    -
    - {{ embed_video_player(proposal.session.views.video) }} -
    -
    - {{ video_action_bar(proposal.session.views.video, '', proposal.session, false)}} -
    + {% if proposal.session and proposal.session.views.video and (not proposal.session.is_restricted_video or proposal.session.current_roles.project_participant) %} +
    +
    + {{ embed_video_player(proposal.session.views.video) }}
    - {% set seq = seq + 1 %} - {%- endif -%} - {% if proposal.views.video %} -
    -
    - {{ embed_video_player(proposal.views.video) }} -
    -
    - {{ video_action_bar(proposal.views.video, proposal, '', false) }} -
    +
    + {{ video_action_bar(proposal.session.views.video, '', proposal.session, false) }}
    - {%- elif proposal.current_roles.editor %} -
    {% endif %} {% if proposal.views.video and proposal.session and proposal.session.views.video %}
    {% endif %} + {% endwith %}
    - {% for member in proposal.memberships %} - {%- if not member.is_uncredited %} + {% for membership in proposal.memberships %} + {%- if not membership.is_uncredited %}
    - {{ useravatar(member.user) }} + {{ useravatar(membership.member) }}

    - {{ member.user.fullname }} + {{ membership.member.fullname }}

    - {%- if member.user.username %} + {%- if membership.member.username %}

    - @{{ member.user.username }} - {% if member.label -%} - {{ member.label }} + @{{ membership.member.username }} + {% if membership.label -%} + {{ membership.label }} {% endif -%}

    {%- endif -%} @@ -411,8 +427,8 @@
    {% endblock left_col %} {% block footerinnerscripts %} - - + + - + {% endblock innerscripts %} diff --git a/funnel/templates/ticket_event.html.jinja2 b/funnel/templates/ticket_event.html.jinja2 index 60e9acbde..b724a4809 100644 --- a/funnel/templates/ticket_event.html.jinja2 +++ b/funnel/templates/ticket_event.html.jinja2 @@ -6,7 +6,7 @@ {% block title %}{% trans %}Setup events{% endtrans %}{% endblock title %} {% block pageheaders %} - + {% endblock pageheaders %} {% block top_title %} @@ -28,23 +28,23 @@
    {{ checkin_count() }}
    -
    +
    -
    +
      -
    • Badges to be printed
    • -
    • Label badges to be printed
    • -
    • Badges printed
    • +
    • {% trans %}Badges{% endtrans %}
    • +
    • {% trans %}Label badges{% endtrans %}
    • +
    • {% trans %}Printed Badges{% endtrans %}
    • {{ badge_form.hidden_tag() }} @@ -61,11 +61,11 @@ - - - - - + + + + + {{ participant_list() }} @@ -75,7 +75,7 @@ {% endblock contentwrapper %} {% block footerscripts %} - + + + {{ ajaxform(ref_id='form-update', request=request) }} + {%- endblock pageheaders %} -{% block bodyattrs %}class="bg-primary mobile-header"{% endblock bodyattrs %} - -{% block contenthead %} -{% endblock contenthead %} - -{% block baseheadline %} - {{ profile_header(profile) }} -{% endblock baseheadline %} - {% block basecontent %}
      diff --git a/funnel/templates/user_profile_projects.html.jinja2 b/funnel/templates/user_profile_projects.html.jinja2 index 679e65aef..feb9a523d 100644 --- a/funnel/templates/user_profile_projects.html.jinja2 +++ b/funnel/templates/user_profile_projects.html.jinja2 @@ -3,7 +3,7 @@ {% block bodyattrs %}class="bg-primary no-sticky-header mobile-header"{% endblock bodyattrs %} {% block baseheadline %} - {{ profile_header(profile, class="mui--hidden-xs mui--hidden-sm", current_page="projects", title=gettext("Projects")) }} + {{ profile_header(profile, class="mui--hidden-xs mui--hidden-sm", current_page="projects", title=_("Projects")) }} {% endblock baseheadline %} {% block contentwrapper %} diff --git a/funnel/templates/venues.html.jinja2 b/funnel/templates/venues.html.jinja2 index 613e33eae..dde6a90df 100644 --- a/funnel/templates/venues.html.jinja2 +++ b/funnel/templates/venues.html.jinja2 @@ -4,7 +4,7 @@ {% block top_title %}

      {{ project.title }}

      -

      Venues

      +

      {% trans %}Venues{% endtrans %}

      {% endblock top_title %} {% block contentwrapper %} @@ -22,8 +22,8 @@
    • {# djlint:off #}{# djlint:on #} - {{ faicon(icon='edit', icon_size='subhead', css_class="fa5--link") }} -    {{ faicon(icon='trash-alt', icon_size='subhead', css_class="fa5--link") }} + {{ faicon(icon='edit', icon_size='subhead', css_class="fa5--link") }} +    {{ faicon(icon='trash-alt', icon_size='subhead', css_class="fa5--link") }}
    • {% else %} @@ -54,8 +54,8 @@
    • {{ room.title }} - {{ faicon(icon='edit', icon_size='subhead', css_class="fa5--link") }} -    {{ faicon(icon='trash-alt', icon_size='subhead', css_class="fa5--link") }} + {{ faicon(icon='edit', icon_size='subhead', css_class="fa5--link") }} +    {{ faicon(icon='trash-alt', icon_size='subhead', css_class="fa5--link") }}
      {{ room.description }}
    • diff --git a/funnel/translations/hi_IN/LC_MESSAGES/messages.mo b/funnel/translations/hi_IN/LC_MESSAGES/messages.mo index 12a7aa6c0..9f2b47836 100644 Binary files a/funnel/translations/hi_IN/LC_MESSAGES/messages.mo and b/funnel/translations/hi_IN/LC_MESSAGES/messages.mo differ diff --git a/funnel/translations/hi_IN/LC_MESSAGES/messages.po b/funnel/translations/hi_IN/LC_MESSAGES/messages.po index 1537ad336..adfde302b 100644 --- a/funnel/translations/hi_IN/LC_MESSAGES/messages.po +++ b/funnel/translations/hi_IN/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: webmaster@hasgeek.com\n" -"POT-Creation-Date: 2022-11-15 18:44+0530\n" +"POT-Creation-Date: 2023-04-23 06:11+0530\n" "PO-Revision-Date: 2020-12-17 13:47+0530\n" "Last-Translator: Ritesh Raj\n" "Language: hi\n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.11.0\n" +"Generated-By: Babel 2.12.1\n" #: funnel/registry.py:77 msgid "A Bearer token is required in the Authorization header" @@ -34,11 +34,11 @@ msgstr "एक्सेस टोकन अज्ञात" msgid "Access token has expired" msgstr "एक्सेस टोकन के समय सीमा समाप्त हो चुकी है" -#: funnel/registry.py:103 +#: funnel/registry.py:102 msgid "Token does not provide access to this resource" msgstr "यह टोकन इस सामग्री की एक्सेस प्रदान नहीं करता" -#: funnel/registry.py:107 +#: funnel/registry.py:106 msgid "This resource can only be accessed by trusted clients" msgstr "इस सामग्री को सिर्फ ट्रस्टेड क्लाइंट ही एक्सेस कर सकते हैं" @@ -60,31 +60,31 @@ msgstr "" msgid "An error occured when submitting the form" msgstr "" -#: funnel/assets/js/form.js:23 +#: funnel/assets/js/form.js:24 msgid "Saving" msgstr "" -#: funnel/assets/js/form.js:41 +#: funnel/assets/js/form.js:42 msgid "Changes saved but not published" msgstr "" -#: funnel/assets/js/form.js:58 +#: funnel/assets/js/form.js:59 msgid "You have unsaved changes on this page. Do you want to leave this page?" msgstr "" -#: funnel/assets/js/form.js:71 +#: funnel/assets/js/form.js:72 msgid "These changes have not been published yet" msgstr "" -#: funnel/assets/js/project_header.js:51 +#: funnel/assets/js/project_header.js:52 msgid "The server is experiencing difficulties. Try again in a few minutes" msgstr "" -#: funnel/assets/js/project_header.js:58 +#: funnel/assets/js/project_header.js:59 msgid "This device has no internet connection" msgstr "इस उपकरण का कोई इंटरनेट कनेक्शन नहीं है" -#: funnel/assets/js/project_header.js:60 +#: funnel/assets/js/project_header.js:61 msgid "" "Unable to connect. If this device is behind a firewall or using any " "script blocking extension (like Privacy Badger), please ensure your " @@ -122,6 +122,7 @@ msgid "Today" msgstr "" #: funnel/assets/js/utils/helper.js:237 +#: funnel/templates/room_updates.html.jinja2:30 msgid "Tomorrow" msgstr "" @@ -134,23 +135,23 @@ msgstr "" msgid "In %d days" msgstr "" -#: funnel/assets/js/utils/helper.js:316 funnel/assets/js/utils/helper.js:326 +#: funnel/assets/js/utils/helper.js:343 funnel/assets/js/utils/helper.js:353 msgid "Link copied" msgstr "" -#: funnel/assets/js/utils/helper.js:317 funnel/assets/js/utils/helper.js:328 +#: funnel/assets/js/utils/helper.js:344 funnel/assets/js/utils/helper.js:355 msgid "Could not copy link" msgstr "" -#: funnel/forms/account.py:55 +#: funnel/forms/account.py:58 msgid "English" msgstr "अंग्रेज़ी" -#: funnel/forms/account.py:56 +#: funnel/forms/account.py:59 msgid "Hindi (beta; incomplete)" msgstr "हिंदी (बीटा; अधूरा)" -#: funnel/forms/account.py:63 +#: funnel/forms/account.py:66 msgid "" "This password is too simple. Add complexity by making it longer and using" " a mix of upper and lower case letters, numbers and symbols" @@ -158,66 +159,66 @@ msgstr "" "यह पासवर्ड काफी आसान है. इसे लंबा बनाकर और अंग्रेजी के बड़े और छोटे " "अक्षरों, नंबर तथा चिन्हों का उपयोग करके इसे कठिन बनाएं" -#: funnel/forms/account.py:145 +#: funnel/forms/account.py:148 msgid "This password was found in a breached password list and is not safe to use" msgstr "" -#: funnel/forms/account.py:163 funnel/forms/account.py:190 -#: funnel/forms/login.py:139 +#: funnel/forms/account.py:166 funnel/forms/account.py:193 +#: funnel/forms/login.py:146 msgid "Password" msgstr "पासवर्ड" -#: funnel/forms/account.py:174 funnel/forms/account.py:372 -#: funnel/forms/login.py:37 +#: funnel/forms/account.py:177 funnel/forms/account.py:375 +#: funnel/forms/login.py:39 msgid "Incorrect password" msgstr "गलत पासवर्ड" -#: funnel/forms/account.py:231 funnel/forms/account.py:291 -#: funnel/forms/login.py:124 +#: funnel/forms/account.py:234 funnel/forms/account.py:294 +#: funnel/forms/login.py:131 msgid "Phone number or email address" msgstr "" -#: funnel/forms/account.py:244 +#: funnel/forms/account.py:247 msgid "Could not find a user with that id" msgstr "इस आईडी से जुड़ा कोई यूजर नहीं मिला" -#: funnel/forms/account.py:258 funnel/forms/account.py:302 -#: funnel/forms/account.py:348 +#: funnel/forms/account.py:261 funnel/forms/account.py:305 +#: funnel/forms/account.py:351 msgid "New password" msgstr "नया पासवर्ड" -#: funnel/forms/account.py:268 funnel/forms/account.py:312 -#: funnel/forms/account.py:358 +#: funnel/forms/account.py:271 funnel/forms/account.py:315 +#: funnel/forms/account.py:361 msgid "Confirm password" msgstr "पासवर्ड की पुष्टि करें" -#: funnel/forms/account.py:293 +#: funnel/forms/account.py:296 msgid "Please reconfirm your phone number, email address or username" msgstr "" -#: funnel/forms/account.py:326 +#: funnel/forms/account.py:329 msgid "This does not match the user the reset code is for" msgstr "" -#: funnel/forms/account.py:340 +#: funnel/forms/account.py:343 msgid "Current password" msgstr "वर्तमान पासवर्ड" -#: funnel/forms/account.py:370 +#: funnel/forms/account.py:373 msgid "Not logged in" msgstr "लॉगिन नहीं है" -#: funnel/forms/account.py:378 funnel/forms/account.py:483 +#: funnel/forms/account.py:381 funnel/forms/account.py:482 msgid "This is required" msgstr "यह आवश्यक है" -#: funnel/forms/account.py:380 +#: funnel/forms/account.py:383 msgid "This is too long" msgstr "यह काफी लंबा है" -#: funnel/forms/account.py:383 -msgid "Usernames can only have alphabets, numbers and dashes (except at the ends)" -msgstr "यूजरनेम में सिर्फ वर्णमाला, नंबर और डैश (अंत में छोड़कर) हो सकते हैं" +#: funnel/forms/account.py:386 +msgid "Usernames can only have alphabets, numbers and underscores" +msgstr "" #: funnel/forms/account.py:389 msgid "This username is reserved" @@ -239,24 +240,20 @@ msgstr "शुभ नाम" msgid "This is your name, not of your organization" msgstr "" -#: funnel/forms/account.py:412 funnel/forms/account.py:482 +#: funnel/forms/account.py:412 funnel/forms/account.py:481 #: funnel/forms/organization.py:37 msgid "Username" msgstr "यूजरनेम" #: funnel/forms/account.py:413 -msgid "" -"Single word that can contain letters, numbers and dashes. You need a " -"username to have a public account page" +msgid "A single word that is uniquely yours, for your account page and @mentions" msgstr "" -"एक ही शब्द जिसमें कि अक्षर, नंबर और डैश हों. आपको पब्लिक अकाउंट बनाने के " -"लिए यूजरनेम की आवश्यकता है" -#: funnel/forms/account.py:429 funnel/forms/project.py:93 +#: funnel/forms/account.py:428 funnel/forms/project.py:98 msgid "Timezone" msgstr "समय क्षेत्र" -#: funnel/forms/account.py:430 +#: funnel/forms/account.py:429 msgid "" "Where in the world are you? Dates and times will be shown in your local " "timezone" @@ -264,81 +261,63 @@ msgstr "" "आप फिलहाल कहां पर हैं? समय और तारीख आपको अपने स्थानीय समय क्षेत्र में " "दिखेंगे" -#: funnel/forms/account.py:438 +#: funnel/forms/account.py:437 msgid "Use your device’s timezone" msgstr "अपने डिवाइस के समय क्षेत्र का उपयोग करें" -#: funnel/forms/account.py:440 +#: funnel/forms/account.py:439 msgid "Locale" msgstr "स्थान" -#: funnel/forms/account.py:441 +#: funnel/forms/account.py:440 msgid "Your preferred UI language" msgstr "आपकी पसंदीदा UI भाषा" -#: funnel/forms/account.py:444 +#: funnel/forms/account.py:443 msgid "Use your device’s language" msgstr "अपने डिवाइस की भाषा का उपयोग करें" -#: funnel/forms/account.py:459 +#: funnel/forms/account.py:458 msgid "I understand that deletion is permanent and my account cannot be recovered" msgstr "" -#: funnel/forms/account.py:462 funnel/forms/account.py:471 +#: funnel/forms/account.py:461 funnel/forms/account.py:470 msgid "You must accept this" msgstr "" -#: funnel/forms/account.py:465 +#: funnel/forms/account.py:464 msgid "" "I understand that deleting my account will remove personal details such " "as my name and contact details, but not messages sent to other users, or " "public content such as comments, job posts and submissions to projects" msgstr "" -#: funnel/forms/account.py:470 +#: funnel/forms/account.py:469 msgid "Public content must be deleted individually" msgstr "" -#: funnel/forms/account.py:507 +#: funnel/forms/account.py:506 msgid "This email address is pending verification" msgstr "इस ईमेल पते की पुष्टि अभी बाकी है" -#: funnel/forms/account.py:519 funnel/forms/account.py:549 +#: funnel/forms/account.py:518 funnel/forms/account.py:538 msgid "Email address" msgstr "ईमेल पता" -#: funnel/forms/account.py:533 -msgid "Type" -msgstr "प्रकार" - -#: funnel/forms/account.py:537 funnel/templates/layout.html.jinja2:92 -#: funnel/templates/layout.html.jinja2:96 -#: funnel/templates/macros.html.jinja2:536 -msgid "Home" -msgstr "मुखपृष्ठ" - -#: funnel/forms/account.py:538 -msgid "Work" -msgstr "व्यवसाय" - -#: funnel/forms/account.py:539 -msgid "Other" -msgstr "अन्य" - -#: funnel/forms/account.py:568 funnel/forms/account.py:616 -#: funnel/forms/sync_ticket.py:128 +#: funnel/forms/account.py:557 funnel/forms/account.py:584 +#: funnel/forms/sync_ticket.py:138 msgid "Phone number" msgstr "फोन नंबर" -#: funnel/forms/account.py:571 +#: funnel/forms/account.py:563 msgid "Mobile numbers only, in Indian or international format" msgstr "केवल मोबाइल नंबर, भारतीय या अंतर्राष्ट्रीय फॉर्मेट में" -#: funnel/forms/account.py:576 +#: funnel/forms/account.py:570 msgid "Send notifications by SMS" msgstr "SMS द्वारा नोटिफिकेशन भेजें" -#: funnel/forms/account.py:577 +#: funnel/forms/account.py:571 msgid "" "Unsubscribe anytime, and control what notifications are sent from the " "Notifications tab under account settings" @@ -346,24 +325,7 @@ msgstr "" "कभी भी सदस्यता समाप्त करें, और कौन सी नोटिफिकेशन भेजी जाती हैं, इसे खाता " "सेटिंग के तहत नोटिफिकेशन टैब से नियंत्रित करें" -#: funnel/forms/account.py:590 funnel/forms/login.py:43 -#: funnel/transports/sms/send.py:186 -msgid "This phone number cannot receive SMS messages" -msgstr "यह फोन नंबर SMS मैसेज प्राप्त नहीं कर सकता है" - -#: funnel/forms/account.py:594 -msgid "This does not appear to be a valid phone number" -msgstr "यह एक मान्य फोन नंबर प्रतीत नहीं होता है" - -#: funnel/forms/account.py:601 -msgid "You have already registered this phone number" -msgstr "आपने इस फोन नंबर को पहले ही रजिस्टर कर लिया है" - -#: funnel/forms/account.py:605 -msgid "This phone number has already been claimed" -msgstr "यह फोन नंबर पहले से ही इस्तेमाल में है" - -#: funnel/forms/account.py:630 +#: funnel/forms/account.py:598 msgid "Report type" msgstr "रिपोर्ट टाइप" @@ -390,7 +352,7 @@ msgid "A description to help users recognize your application" msgstr "यूजर द्वारा आपके ऐप्लिकेशन की पहचान करने में मदद के लिए एक विवरण" #: funnel/forms/auth_client.py:52 -#: funnel/templates/account_organizations.html.jinja2:52 +#: funnel/templates/account_organizations.html.jinja2:57 #: funnel/templates/auth_client.html.jinja2:41 #: funnel/templates/auth_client_index.html.jinja2:16 #: funnel/templates/js/membership.js.jinja2:99 @@ -478,13 +440,13 @@ msgstr "" msgid "Permission ‘{perm}’ is malformed" msgstr "अनुमति ‘{perm}’ गलत बनाया गया है" -#: funnel/forms/auth_client.py:177 funnel/forms/membership.py:22 -#: funnel/forms/membership.py:45 funnel/forms/proposal.py:197 +#: funnel/forms/auth_client.py:177 funnel/forms/membership.py:23 +#: funnel/forms/membership.py:46 funnel/forms/proposal.py:198 #: funnel/templates/siteadmin_comments.html.jinja2:51 msgid "User" msgstr "यूजर" -#: funnel/forms/auth_client.py:179 funnel/forms/organization.py:108 +#: funnel/forms/auth_client.py:179 funnel/forms/organization.py:104 msgid "Lookup a user by their username or email address" msgstr "किसी यूजर को उनके यूजरनेम या ईमेल पते से खोजें" @@ -505,15 +467,15 @@ msgstr "अनुमति देने के लिए टीम का चय msgid "Unknown team" msgstr "अज्ञात टीम" -#: funnel/forms/comment.py:29 funnel/templates/submission.html.jinja2:43 +#: funnel/forms/comment.py:29 funnel/templates/submission.html.jinja2:94 msgid "Get notifications" msgstr "" -#: funnel/forms/helpers.py:79 -msgid "This email address has been claimed by someone else" -msgstr "इस ईमेल पते का इस्तेमाल किसी और के द्वारा किया जा चुका है" +#: funnel/forms/helpers.py:89 +msgid "This email address is linked to another account" +msgstr "" -#: funnel/forms/helpers.py:82 +#: funnel/forms/helpers.py:92 msgid "" "This email address is already registered. You may want to try logging in " "or resetting your password" @@ -521,11 +483,11 @@ msgstr "" "यह ईमेल पता पहले से रजिस्टर है. आपको लॉगिन करनी चाहिए या फिर अपने पासवर्ड" " को रीसेट करनी चाहिए" -#: funnel/forms/helpers.py:89 +#: funnel/forms/helpers.py:99 msgid "This does not appear to be a valid email address" msgstr "यह ईमेल पता सही नहीं लग रहा है" -#: funnel/forms/helpers.py:93 +#: funnel/forms/helpers.py:103 msgid "" "The domain name of this email address is missing a DNS MX record. We " "require an MX record as missing MX is a strong indicator of spam. Please " @@ -535,11 +497,11 @@ msgstr "" "आवश्यकता होती है क्योंकि गैर-मौजूद MX वाले ईमेल को फर्ज़ी माना जाता है. " "कृपया अपने वेबसाइट निर्माणकर्ता से DNS में MX जोड़ने को बोलें" -#: funnel/forms/helpers.py:101 +#: funnel/forms/helpers.py:111 msgid "You have already registered this email address" msgstr "आपने पहले ही इस ईमेल को रजिस्टर कर लिया है" -#: funnel/forms/helpers.py:107 +#: funnel/forms/helpers.py:117 msgid "" "This email address appears to be having temporary problems with receiving" " email. Please use another if necessary" @@ -547,7 +509,7 @@ msgstr "" "इस ईमेल पते पर फिलहाल ईमेल पाए जाने में समस्या नज़र आ रही है. अगर कुछ " "महत्वपूर्ण ह" -#: funnel/forms/helpers.py:115 +#: funnel/forms/helpers.py:126 msgid "" "This email address is no longer valid. If you believe this to be " "incorrect, email {support} asking for the address to be activated" @@ -555,7 +517,12 @@ msgstr "" "यह ईमेल पता अब वैध नहीं है. अगर फिर भी आपको ये गलत लगता है, तो इस पते को " "सक्रिय करने के लिए {support} को ईमेल भेजें" -#: funnel/forms/helpers.py:128 +#: funnel/forms/helpers.py:133 funnel/forms/login.py:38 +#: funnel/views/account.py:524 +msgid "This email address has been blocked from use" +msgstr "" + +#: funnel/forms/helpers.py:145 msgid "" "You or someone else has made an account with this email address but has " "not confirmed it. Do you need to reset your password?" @@ -563,15 +530,47 @@ msgstr "" "आपने या किसी और ने इसे ईमेल पते से एक अकाउंट बनाया था, पर उसकी पुष्टि " "नहीं की है. क्या आप अपना पासवर्ड रीसेट करना चाहेंगे?" -#: funnel/forms/helpers.py:141 funnel/forms/project.py:163 +#: funnel/forms/helpers.py:178 funnel/forms/login.py:45 +#: funnel/transports/sms/send.py:226 +msgid "This phone number cannot receive SMS messages" +msgstr "यह फोन नंबर SMS मैसेज प्राप्त नहीं कर सकता है" + +#: funnel/forms/helpers.py:182 funnel/forms/helpers.py:203 +msgid "This does not appear to be a valid phone number" +msgstr "यह एक मान्य फोन नंबर प्रतीत नहीं होता है" + +#: funnel/forms/helpers.py:193 +msgid "This phone number is linked to another account" +msgstr "" + +#: funnel/forms/helpers.py:196 +msgid "" +"This phone number is already registered. You may want to try logging in " +"or resetting your password" +msgstr "" + +#: funnel/forms/helpers.py:207 +msgid "You have already registered this phone number" +msgstr "आपने इस फोन नंबर को पहले ही रजिस्टर कर लिया है" + +#: funnel/forms/helpers.py:211 funnel/forms/login.py:46 +#: funnel/views/account.py:552 +msgid "This phone number has been blocked from use" +msgstr "" + +#: funnel/forms/helpers.py:225 funnel/forms/project.py:168 msgid "A https:// URL is required" msgstr "https:// URL आवश्यक है" -#: funnel/forms/helpers.py:142 +#: funnel/forms/helpers.py:226 msgid "Images must be hosted at images.hasgeek.com" msgstr "तस्वीरें images.hasgeek.com पर होस्ट की जानी चाहिए" -#: funnel/forms/label.py:18 funnel/forms/project.py:306 +#: funnel/forms/helpers.py:237 funnel/forms/helpers.py:247 +msgid "This video URL is not supported" +msgstr "" + +#: funnel/forms/label.py:18 funnel/forms/project.py:312 msgid "Label" msgstr "लेबल" @@ -601,55 +600,51 @@ msgstr "" msgid "Option" msgstr "विकल्प" -#: funnel/forms/login.py:36 -msgid "This email address has been blocked from use" -msgstr "" - -#: funnel/forms/login.py:38 +#: funnel/forms/login.py:40 msgid "" "This account could not be identified. Try with a phone number or email " "address" msgstr "" -#: funnel/forms/login.py:41 +#: funnel/forms/login.py:43 msgid "OTP is incorrect" msgstr "" -#: funnel/forms/login.py:42 +#: funnel/forms/login.py:44 msgid "That does not appear to be a valid login session" msgstr "यह कोई वैध लॉग इन सेशन नहीं लग रहा" -#: funnel/forms/login.py:70 +#: funnel/forms/login.py:74 msgid "Password is required" msgstr "पासवर्ड आवश्यक है" -#: funnel/forms/login.py:127 +#: funnel/forms/login.py:134 msgid "A phone number or email address is required" msgstr "" -#: funnel/forms/login.py:144 +#: funnel/forms/login.py:151 #, python-format msgid "Password must be under %(max)s characters" msgstr "पासवर्ड %(max)s पात्र से ज्यादा का नहीं हो सकता" -#: funnel/forms/login.py:248 +#: funnel/forms/login.py:260 msgid "Session id" msgstr "सेशन आईडी" -#: funnel/forms/login.py:266 funnel/forms/login.py:302 -#: funnel/views/account.py:260 +#: funnel/forms/login.py:278 funnel/forms/login.py:314 +#: funnel/views/account.py:270 msgid "OTP" msgstr "" -#: funnel/forms/login.py:267 funnel/forms/login.py:303 +#: funnel/forms/login.py:279 funnel/forms/login.py:315 msgid "One-time password sent to your device" msgstr "" -#: funnel/forms/login.py:291 funnel/forms/profile.py:68 +#: funnel/forms/login.py:303 funnel/forms/profile.py:68 msgid "Your name" msgstr "आपका नाम" -#: funnel/forms/login.py:292 +#: funnel/forms/login.py:304 msgid "" "This account is for you as an individual. We’ll make one for your " "organization later" @@ -657,77 +652,94 @@ msgstr "" "यह खाता आपके लिए व्यक्तिगत तौर पर है. हम आपके संगठन के लिए बाद में दूसरा " "खाता बना देंगे" -#: funnel/forms/membership.py:23 funnel/forms/membership.py:46 +#: funnel/forms/membership.py:24 funnel/forms/membership.py:47 msgid "Please select a user" msgstr "कोई यूजर चुनें" -#: funnel/forms/membership.py:24 funnel/forms/membership.py:47 -#: funnel/forms/proposal.py:198 +#: funnel/forms/membership.py:25 funnel/forms/membership.py:48 +#: funnel/forms/proposal.py:199 msgid "Find a user by their name or email address" msgstr "नाम या ईमेल पते द्वारा यूजर को खोजें" -#: funnel/forms/membership.py:27 +#: funnel/forms/membership.py:28 msgid "Access level" msgstr "एक्सेस लेवल" -#: funnel/forms/membership.py:33 +#: funnel/forms/membership.py:34 msgid "Admin (can manage projects, but can’t add or remove other admins)" msgstr "" "एडमिन (प्रोजेक्ट प्रबंधित कर सकते हैं, पर दूसरे एडमिन को जोड़ या हटा नहीं" " सकते)" -#: funnel/forms/membership.py:35 +#: funnel/forms/membership.py:36 msgid "Owner (can also manage other owners and admins)" msgstr "ओनर (दूसरे ओनर और एडमिन को भी प्रबंधित कर सकते हैं)" -#: funnel/forms/membership.py:50 funnel/models/comment.py:363 +#: funnel/forms/membership.py:51 funnel/models/comment.py:370 #: funnel/templates/js/membership.js.jinja2:119 msgid "Editor" msgstr "संपादक" -#: funnel/forms/membership.py:52 +#: funnel/forms/membership.py:53 msgid "Can edit project details, proposal guidelines, schedule, labels and venues" msgstr "" "प्रोजेक्ट विवरण, प्रस्ताव के दिशा निर्देश, कार्यक्रम, लेबल और स्थानों की " "जानकारी संपादित कर सकते हैं" -#: funnel/forms/membership.py:57 funnel/models/comment.py:365 +#: funnel/forms/membership.py:58 funnel/models/comment.py:372 #: funnel/templates/js/membership.js.jinja2:120 msgid "Promoter" msgstr "" -#: funnel/forms/membership.py:59 +#: funnel/forms/membership.py:60 msgid "Can manage participants and see contact info" msgstr "प्रतिभागियों तथा उनके संपर्क की जानकारी को प्रबंधित कर सकते हैं" -#: funnel/forms/membership.py:62 funnel/templates/js/membership.js.jinja2:121 +#: funnel/forms/membership.py:63 funnel/templates/js/membership.js.jinja2:121 msgid "Usher" msgstr "मार्गदर्शक" -#: funnel/forms/membership.py:64 +#: funnel/forms/membership.py:65 msgid "Can check-in a participant using their badge at a physical event" msgstr "" "किसी वास्तविक इवेंट में प्रतिभागियों के बैच की जांच करके अंदर आने की " "अनुमति दे सकते हैं" -#: funnel/forms/membership.py:83 +#: funnel/forms/membership.py:70 funnel/forms/proposal.py:203 +#: funnel/templates/js/membership.js.jinja2:24 +msgid "Role" +msgstr "" + +#: funnel/forms/membership.py:71 +msgid "Optional – Name this person’s role" +msgstr "" + +#: funnel/forms/membership.py:79 +msgid "Select one or more roles" +msgstr "" + +#: funnel/forms/membership.py:89 msgid "Choice" msgstr "विकल्प" -#: funnel/forms/membership.py:84 funnel/models/membership_mixin.py:64 +#: funnel/forms/membership.py:90 funnel/models/membership_mixin.py:67 +#: funnel/templates/membership_invite_actions.html.jinja2:15 msgid "Accept" msgstr "स्वीकारें" -#: funnel/forms/membership.py:84 +#: funnel/forms/membership.py:90 +#: funnel/templates/membership_invite_actions.html.jinja2:16 msgid "Decline" msgstr "अस्वीकारें" -#: funnel/forms/membership.py:85 +#: funnel/forms/membership.py:91 msgid "Please make a choice" msgstr "कृपया कोई विकल्प चुनें" -#: funnel/forms/notification.py:40 funnel/forms/sync_ticket.py:123 -#: funnel/templates/macros.html.jinja2:96 +#: funnel/forms/notification.py:40 funnel/forms/sync_ticket.py:133 +#: funnel/templates/macros.html.jinja2:86 +#: funnel/templates/ticket_event.html.jinja2:66 +#: funnel/templates/ticket_type.html.jinja2:27 msgid "Email" msgstr "ईमेल" @@ -907,39 +919,75 @@ msgstr "चयनित WhatsApp नोटिफिकेशन अक्षम msgid "Disabled this WhatsApp notification" msgstr "यह WhatsApp नोटिफिकेशन अक्षम कर दिया गया" -#: funnel/forms/notification.py:127 +#: funnel/forms/notification.py:104 +msgid "Signal" +msgstr "" + +#: funnel/forms/notification.py:105 +msgid "To enable, add your Signal number" +msgstr "" + +#: funnel/forms/notification.py:107 +msgid "Notify me on Signal (beta)" +msgstr "" + +#: funnel/forms/notification.py:108 +msgid "Uncheck this to disable all Signal notifications" +msgstr "" + +#: funnel/forms/notification.py:109 +msgid "Signal notifications" +msgstr "" + +#: funnel/forms/notification.py:110 +msgid "Enabled selected Signal notifications" +msgstr "" + +#: funnel/forms/notification.py:111 +msgid "Enabled this Signal notification" +msgstr "" + +#: funnel/forms/notification.py:112 +msgid "Disabled all Signal notifications" +msgstr "" + +#: funnel/forms/notification.py:113 +msgid "Disabled this Signal notification" +msgstr "" + +#: funnel/forms/notification.py:139 msgid "Notify me" msgstr "सूचित करें" -#: funnel/forms/notification.py:127 +#: funnel/forms/notification.py:139 msgid "Uncheck this to disable all notifications" msgstr "सभी नोटिफिकेशन अक्षम करने के लिए इसे चिन्हित से हटाएं" -#: funnel/forms/notification.py:131 +#: funnel/forms/notification.py:143 msgid "Or disable only a specific notification" msgstr "या सिर्फ कुछ चुनिंदा नोटिफिकेशन ही अक्षम करें" -#: funnel/forms/notification.py:139 +#: funnel/forms/notification.py:151 msgid "Unsubscribe token" msgstr "अनसब्सक्राइब टोकन" -#: funnel/forms/notification.py:142 +#: funnel/forms/notification.py:154 msgid "Unsubscribe token type" msgstr "अनसब्सक्राइब टोकन प्रकार" -#: funnel/forms/notification.py:201 +#: funnel/forms/notification.py:213 msgid "Notification type" msgstr "नोटिफिकेशन का प्रकार" -#: funnel/forms/notification.py:203 +#: funnel/forms/notification.py:215 msgid "Transport" msgstr "यातायात" -#: funnel/forms/notification.py:205 +#: funnel/forms/notification.py:217 msgid "Enable this transport" msgstr "यह यातायात सुविधा सक्षम करें" -#: funnel/forms/notification.py:210 +#: funnel/forms/notification.py:222 msgid "Main switch" msgstr "मेन स्विच" @@ -953,23 +1001,20 @@ msgstr "आपके संगठन का नाम, बगैर किसी #: funnel/forms/organization.py:38 msgid "" -"A short name for your organization’s account page. Single word containing" -" letters, numbers and dashes only. Pick something permanent: changing it " -"will break existing links from around the web" +"A unique word for your organization’s account page. Alphabets, numbers " +"and underscores are okay. Pick something permanent: changing it will " +"break links" msgstr "" -"आपके संगठन के अकाउंट पेज के लिए एक संक्षिप्त नाम. एक शब्द जिसमें केवल " -"अक्षर, नंबर और डैश ही शामिल हों. कुछ स्थाई सा नाम रखें: इसे बदलने से वेब " -"पर मौजूद पुराने लिंक काम नहीं करेंगे" -#: funnel/forms/organization.py:60 -msgid "Names can only have letters, numbers and dashes (except at the ends)" -msgstr "नाम में केवल अक्षर, नंबर और डैश (अंत में छोड़कर) हो सकते हैं" +#: funnel/forms/organization.py:59 +msgid "Names can only have alphabets, numbers and underscores" +msgstr "" -#: funnel/forms/organization.py:66 +#: funnel/forms/organization.py:62 msgid "This name is reserved" msgstr "यह नाम रिज़र्व है" -#: funnel/forms/organization.py:75 +#: funnel/forms/organization.py:71 msgid "" "This is your current username. You must change it first from your account before you can assign it to an " @@ -978,27 +1023,27 @@ msgstr "" "यह आपका मौजूदा यूजरनेम है. इसे किसी संगठन को सौंपने से पहले आपको" " अपने अकाउंट से बदलना होगा" -#: funnel/forms/organization.py:83 +#: funnel/forms/organization.py:79 msgid "This name has been taken by another user" msgstr "यह यूजरनेम किसी दूसरे यूजर द्वारा लिया जा चुका है" -#: funnel/forms/organization.py:87 +#: funnel/forms/organization.py:83 msgid "This name has been taken by another organization" msgstr "यह यूजरनेम किसी दूसरे संगठन द्वारा लिया जा चुका है" -#: funnel/forms/organization.py:98 +#: funnel/forms/organization.py:94 msgid "Team name" msgstr "टीम का नाम" -#: funnel/forms/organization.py:106 funnel/templates/auth_client.html.jinja2:66 +#: funnel/forms/organization.py:102 funnel/templates/auth_client.html.jinja2:66 msgid "Users" msgstr "यूजर्स" -#: funnel/forms/organization.py:111 +#: funnel/forms/organization.py:107 msgid "Make this team public" msgstr "इस टीम को सार्वजनिक बनाएं" -#: funnel/forms/organization.py:112 +#: funnel/forms/organization.py:108 msgid "Team members will be listed on the organization’s account page" msgstr "टीम के सदस्य संगठन के अकाउंट पेज पर दिखाए जाएंगे" @@ -1014,11 +1059,11 @@ msgstr "" msgid "Welcome message" msgstr "स्वागत संदेश" -#: funnel/forms/profile.py:40 funnel/forms/profile.py:80 +#: funnel/forms/profile.py:40 funnel/forms/profile.py:78 msgid "Optional – This message will be shown on the account’s page" msgstr "" -#: funnel/forms/profile.py:43 funnel/forms/profile.py:112 +#: funnel/forms/profile.py:43 funnel/forms/profile.py:110 msgid "Account image" msgstr "अकाउंट चित्र" @@ -1036,103 +1081,100 @@ msgstr "" #: funnel/forms/profile.py:73 msgid "" -"A short name for mentioning you with @username, and the URL to your " -"account’s page. Single word containing letters, numbers and dashes only. " -"Pick something permanent: changing it will break existing links from " -"around the web" +"A single word that is uniquely yours, for your account page and " +"@mentions. Pick something permanent: changing it will break existing " +"links" msgstr "" -"@username और आपके अकाउंट पेज की URL के साथ लगाने के लिए एक उपनाम. एक शब्द" -" जिसमें केवल अक्षर, नंबर और डैश मौजूद हों. कुछ स्थाई सा नाम रखें: इसे " -"बदलने से वेब पर मौजूद पुराने लिंक काम नहीं करेंगे" -#: funnel/forms/profile.py:79 +#: funnel/forms/profile.py:77 msgid "More about you" msgstr "" -#: funnel/forms/profile.py:92 +#: funnel/forms/profile.py:90 msgid "Account visibility" msgstr "" -#: funnel/forms/profile.py:139 funnel/forms/project.py:100 -#: funnel/forms/project.py:213 +#: funnel/forms/profile.py:137 funnel/forms/project.py:105 +#: funnel/forms/project.py:219 msgid "Banner image" msgstr "बैनर का चित्र" -#: funnel/forms/project.py:45 funnel/forms/proposal.py:157 -#: funnel/forms/session.py:19 funnel/forms/sync_ticket.py:56 -#: funnel/forms/sync_ticket.py:95 funnel/forms/update.py:17 +#: funnel/forms/project.py:50 funnel/forms/proposal.py:158 +#: funnel/forms/session.py:19 funnel/forms/sync_ticket.py:66 +#: funnel/forms/sync_ticket.py:105 funnel/forms/update.py:17 #: funnel/templates/auth_client_index.html.jinja2:15 +#: funnel/templates/submission_form.html.jinja2:52 msgid "Title" msgstr "शीर्षक" -#: funnel/forms/project.py:50 +#: funnel/forms/project.py:55 msgid "Tagline" msgstr "टैगलाइन" -#: funnel/forms/project.py:53 +#: funnel/forms/project.py:58 msgid "One line description of the project" msgstr "प्रोजेक्ट का संक्षिप्त विवरण" -#: funnel/forms/project.py:56 funnel/forms/venue.py:67 -#: funnel/templates/macros.html.jinja2:794 +#: funnel/forms/project.py:61 funnel/forms/venue.py:67 +#: funnel/templates/macros.html.jinja2:642 #: funnel/templates/past_projects_section.html.jinja2:12 msgid "Location" msgstr "स्थान" -#: funnel/forms/project.py:57 +#: funnel/forms/project.py:62 msgid "“Online” if this is online-only, else the city or region (without quotes)" msgstr "“ऑनलाइन” यदि केवल यही ऑनलाइन हो, तो फिर शहर और क्षेत्र (बिना क्वोट के)" -#: funnel/forms/project.py:62 +#: funnel/forms/project.py:67 msgid "If this project is online-only, use “Online”" msgstr "यदि यह प्रोजेक्ट केवल-ऑनलाइन हो, तो “ऑनलाइन” का इस्तेमाल करें" -#: funnel/forms/project.py:65 +#: funnel/forms/project.py:70 #, python-format msgid "%(max)d characters maximum" msgstr "अधिकतर %(max)d वर्ण" -#: funnel/forms/project.py:71 +#: funnel/forms/project.py:76 msgid "Optional – Starting time" msgstr "" -#: funnel/forms/project.py:76 +#: funnel/forms/project.py:81 msgid "Optional – Ending time" msgstr "" -#: funnel/forms/project.py:80 +#: funnel/forms/project.py:85 msgid "This is required when starting time is specified" msgstr "" -#: funnel/forms/project.py:83 +#: funnel/forms/project.py:88 msgid "This requires a starting time too" msgstr "" -#: funnel/forms/project.py:87 +#: funnel/forms/project.py:92 msgid "This must be after the starting time" msgstr "" -#: funnel/forms/project.py:94 +#: funnel/forms/project.py:99 msgid "The timezone in which this event occurs" msgstr "वह समय-क्षेत्र जिसमें यह ईवेंट होगा" -#: funnel/forms/project.py:109 +#: funnel/forms/project.py:114 msgid "Project description" msgstr "प्रोजेक्ट विवरण" -#: funnel/forms/project.py:111 +#: funnel/forms/project.py:116 msgid "Landing page contents" msgstr "लैंडिंग पेज की सामग्रियां" -#: funnel/forms/project.py:118 +#: funnel/forms/project.py:123 msgid "Quotes are not necessary in the location name" msgstr "स्थान के नाम में क्वोट लगाना आवश्यक नहीं है" -#: funnel/forms/project.py:135 funnel/templates/project_layout.html.jinja2:247 +#: funnel/forms/project.py:140 funnel/templates/project_layout.html.jinja2:213 msgid "Feature this project" msgstr "" -#: funnel/forms/project.py:143 +#: funnel/forms/project.py:148 msgid "" "Livestream URLs. One per line. Must be on YouTube or Vimeo. Must begin " "with https://" @@ -1140,15 +1182,15 @@ msgstr "" "लाइवस्ट्रीम URLs. प्रति लाइन में एक. YouTube या Vimeo पर ही होनी चाहिए. " "https:// से ही शुरुआत होनी चाहिए" -#: funnel/forms/project.py:164 +#: funnel/forms/project.py:169 msgid "Livestream must be on YouTube or Vimeo" msgstr "लाइवस्ट्रीम YouTube या Vimeo पर ही होनी चाहिए" -#: funnel/forms/project.py:179 funnel/templates/project_settings.html.jinja2:48 +#: funnel/forms/project.py:185 funnel/templates/project_settings.html.jinja2:48 msgid "Custom URL" msgstr "कस्टम URL" -#: funnel/forms/project.py:180 +#: funnel/forms/project.py:186 msgid "" "Customize the URL of your project. Use lowercase letters, numbers and " "dashes only. Including a date is recommended" @@ -1156,7 +1198,7 @@ msgstr "" "अपने प्रोजेक्ट के URL को अपने अनुकूल बनाएं. केवल लोअरकेस अक्षर, नंबर और " "डैश का ही इस्तेमाल करें. आप इसमें कोई तिथि भी जोड़ सकते हैं" -#: funnel/forms/project.py:189 +#: funnel/forms/project.py:195 msgid "" "This URL contains unsupported characters. It can contain lowercase " "letters, numbers and hyphens only" @@ -1164,111 +1206,109 @@ msgstr "" "इस URL में कुछ गैर-उपयोगी वर्ण मौजूद हैं. इसमें केवल लोअरकेस अक्षर, नंबर " "और हायफ़न ही रह सकते हैं" -#: funnel/forms/project.py:233 +#: funnel/forms/project.py:239 msgid "Guidelines" msgstr "दिशानिर्देश" -#: funnel/forms/project.py:236 +#: funnel/forms/project.py:242 msgid "" "Set guidelines for the type of submissions your project is accepting, " "your review process, and anything else relevant to the submission" msgstr "" -#: funnel/forms/project.py:242 +#: funnel/forms/project.py:248 msgid "Submissions close at" msgstr "सबमिशन बंद होने का समय" -#: funnel/forms/project.py:243 +#: funnel/forms/project.py:249 msgid "Optional – Leave blank to have no closing date" msgstr "" -#: funnel/forms/project.py:252 +#: funnel/forms/project.py:258 msgid "Closing date must be in the future" msgstr "" -#: funnel/forms/project.py:263 funnel/forms/project.py:332 -#: funnel/forms/proposal.py:228 +#: funnel/forms/project.py:269 funnel/forms/project.py:338 +#: funnel/forms/proposal.py:229 msgid "Status" msgstr "स्थिति" -#: funnel/forms/project.py:276 +#: funnel/forms/project.py:282 msgid "Open submissions" msgstr "सबमिशन को खोलें" -#: funnel/forms/project.py:299 funnel/templates/layout.html.jinja2:115 -#: funnel/templates/macros.html.jinja2:142 +#: funnel/forms/project.py:305 funnel/templates/layout.html.jinja2:178 +#: funnel/templates/macros.html.jinja2:132 msgid "Account" msgstr "अकाउंट" -#: funnel/forms/project.py:302 +#: funnel/forms/project.py:308 msgid "Choose a sponsor" msgstr "" -#: funnel/forms/project.py:307 +#: funnel/forms/project.py:313 msgid "Optional – Label for sponsor" msgstr "" -#: funnel/forms/project.py:310 +#: funnel/forms/project.py:316 msgid "Mark this sponsor as promoted" msgstr "" -#: funnel/forms/project.py:318 +#: funnel/forms/project.py:324 msgid "Save this project?" msgstr "इस प्रोजेक्ट को सहेजें?" -#: funnel/forms/project.py:321 funnel/forms/session.py:75 +#: funnel/forms/project.py:327 funnel/forms/session.py:75 msgid "Note to self" msgstr "खुद याद रखने की बात" -#: funnel/forms/proposal.py:51 funnel/forms/proposal.py:98 +#: funnel/forms/proposal.py:51 funnel/forms/proposal.py:99 msgid "Please select one" msgstr "कृपया, कोई एक चुनें" -#: funnel/forms/proposal.py:118 funnel/templates/submission.html.jinja2:70 +#: funnel/forms/proposal.py:119 funnel/templates/submission.html.jinja2:150 msgid "Feature this submission" msgstr "" -#: funnel/forms/proposal.py:127 funnel/forms/proposal.py:141 -#: funnel/forms/proposal.py:175 funnel/templates/labels.html.jinja2:5 +#: funnel/forms/proposal.py:128 funnel/forms/proposal.py:142 +#: funnel/forms/proposal.py:176 funnel/templates/labels.html.jinja2:6 #: funnel/templates/project_settings.html.jinja2:63 -#: funnel/templates/submission_form.html.jinja2:62 +#: funnel/templates/submission_admin_panel.html.jinja2:29 +#: funnel/templates/submission_form.html.jinja2:58 msgid "Labels" msgstr "लेबल" -#: funnel/forms/proposal.py:162 funnel/forms/update.py:22 +#: funnel/forms/proposal.py:163 funnel/forms/update.py:22 #: funnel/templates/siteadmin_comments.html.jinja2:53 +#: funnel/templates/submission_form.html.jinja2:110 msgid "Content" msgstr "विषय सूची" -#: funnel/forms/proposal.py:165 funnel/templates/submission_form.html.jinja2:72 +#: funnel/forms/proposal.py:166 funnel/templates/submission_form.html.jinja2:73 msgid "Video" msgstr "वीडियो" -#: funnel/forms/proposal.py:173 +#: funnel/forms/proposal.py:174 msgid "YouTube or Vimeo URL (optional)" msgstr "YouTube या Vimeo URL (ऐच्छिक)" -#: funnel/forms/proposal.py:202 funnel/templates/js/membership.js.jinja2:24 -msgid "Role" -msgstr "" - -#: funnel/forms/proposal.py:203 +#: funnel/forms/proposal.py:204 msgid "Optional – A specific role in this submission (like Author or Editor)" msgstr "" -#: funnel/forms/proposal.py:208 +#: funnel/forms/proposal.py:209 msgid "Hide collaborator on submission" msgstr "" -#: funnel/forms/proposal.py:215 +#: funnel/forms/proposal.py:216 msgid "{user} is already a collaborator" msgstr "" -#: funnel/forms/proposal.py:247 +#: funnel/forms/proposal.py:248 msgid "Move proposal to" msgstr "प्रस्ताव को भेजें" -#: funnel/forms/proposal.py:248 +#: funnel/forms/proposal.py:249 msgid "Move this proposal to another project" msgstr "इस प्रस्ताव को दूसरे प्रोजेक्ट में भेजें" @@ -1330,60 +1370,84 @@ msgstr "" msgid "If checked, both free and buy tickets will shown on project" msgstr "" -#: funnel/forms/sync_ticket.py:61 +#: funnel/forms/sync_ticket.py:50 +msgid "This is a subscription" +msgstr "" + +#: funnel/forms/sync_ticket.py:52 +msgid "If not checked, buy tickets button will be shown" +msgstr "" + +#: funnel/forms/sync_ticket.py:55 +msgid "Register button text" +msgstr "" + +#: funnel/forms/sync_ticket.py:57 +msgid "Optional – Use with care to replace the button text" +msgstr "" + +#: funnel/forms/sync_ticket.py:71 msgid "Badge template URL" msgstr "बैच टेम्पलेट URL" -#: funnel/forms/sync_ticket.py:72 funnel/forms/venue.py:27 +#: funnel/forms/sync_ticket.py:72 +msgid "URL of background image for the badge" +msgstr "" + +#: funnel/forms/sync_ticket.py:82 funnel/forms/venue.py:27 #: funnel/forms/venue.py:90 funnel/templates/js/membership.js.jinja2:23 +#: funnel/templates/project_rsvp_list.html.jinja2:11 +#: funnel/templates/ticket_event.html.jinja2:64 +#: funnel/templates/ticket_type.html.jinja2:26 msgid "Name" msgstr "नाम" -#: funnel/forms/sync_ticket.py:77 +#: funnel/forms/sync_ticket.py:87 msgid "Client id" msgstr "क्लाइंट आईडी" -#: funnel/forms/sync_ticket.py:80 +#: funnel/forms/sync_ticket.py:90 msgid "Client event id" msgstr "क्लाइंट ईवेंट आईडी" -#: funnel/forms/sync_ticket.py:83 +#: funnel/forms/sync_ticket.py:93 msgid "Client event secret" msgstr "क्लाइंट ईवेंट सीक्रेट" -#: funnel/forms/sync_ticket.py:86 +#: funnel/forms/sync_ticket.py:96 msgid "Client access token" msgstr "क्लाइंट ईवेंट टोकन" -#: funnel/forms/sync_ticket.py:100 funnel/forms/sync_ticket.py:154 +#: funnel/forms/sync_ticket.py:110 funnel/forms/sync_ticket.py:164 #: funnel/templates/project_admin.html.jinja2:17 #: funnel/templates/project_settings.html.jinja2:88 #: funnel/templates/ticket_event_list.html.jinja2:15 msgid "Events" msgstr "ईवेंट" -#: funnel/forms/sync_ticket.py:118 +#: funnel/forms/sync_ticket.py:128 msgid "Fullname" msgstr "पूरा नाम" -#: funnel/forms/sync_ticket.py:133 funnel/forms/venue.py:46 +#: funnel/forms/sync_ticket.py:143 funnel/forms/venue.py:46 msgid "City" msgstr "शहर" -#: funnel/forms/sync_ticket.py:138 +#: funnel/forms/sync_ticket.py:148 funnel/templates/ticket_event.html.jinja2:67 +#: funnel/templates/ticket_type.html.jinja2:28 msgid "Company" msgstr "कंपनी" -#: funnel/forms/sync_ticket.py:143 +#: funnel/forms/sync_ticket.py:153 msgid "Job title" msgstr "पेशे का नाम" -#: funnel/forms/sync_ticket.py:148 funnel/loginproviders/init_app.py:31 -#: funnel/templates/macros.html.jinja2:97 +#: funnel/forms/sync_ticket.py:158 funnel/loginproviders/init_app.py:31 +#: funnel/templates/macros.html.jinja2:87 msgid "Twitter" msgstr "Twitter" -#: funnel/forms/sync_ticket.py:152 +#: funnel/forms/sync_ticket.py:162 msgid "Badge is printed" msgstr "बैच प्रिंट की हुई है" @@ -1461,7 +1525,7 @@ msgstr "आपने GitHub लॉग इन अनुरोध को ख़ा #: funnel/loginproviders/github.py:45 funnel/loginproviders/linkedin.py:61 #: funnel/loginproviders/zoom.py:49 -msgid "This server's callback URL is misconfigured" +msgid "This server’s callback URL is misconfigured" msgstr "सर्वर की कॉलबैक URL की विन्यास गलत है" #: funnel/loginproviders/github.py:47 funnel/loginproviders/google.py:42 @@ -1493,7 +1557,7 @@ msgstr "Google के ज़रिए लॉग इन करने में msgid "Google" msgstr "Google" -#: funnel/loginproviders/init_app.py:43 funnel/templates/macros.html.jinja2:99 +#: funnel/loginproviders/init_app.py:43 funnel/templates/macros.html.jinja2:89 msgid "LinkedIn" msgstr "LinkedIn" @@ -1542,7 +1606,7 @@ msgstr "" msgid "Zoom had an intermittent problem. Try again?" msgstr "" -#: funnel/models/auth_client.py:560 +#: funnel/models/auth_client.py:546 msgid "Unrecognized algorithm ‘{value}’" msgstr "अज्ञात ऍल्गोरिथम ‘{value}’" @@ -1550,7 +1614,7 @@ msgstr "अज्ञात ऍल्गोरिथम ‘{value}’" msgid "Disabled" msgstr "" -#: funnel/models/comment.py:37 funnel/models/project.py:409 +#: funnel/models/comment.py:37 funnel/models/project.py:405 msgid "Open" msgstr "जारी" @@ -1562,7 +1626,7 @@ msgstr "" msgid "Collaborators-only" msgstr "" -#: funnel/models/comment.py:46 funnel/models/proposal.py:47 +#: funnel/models/comment.py:46 funnel/models/proposal.py:45 msgid "Submitted" msgstr "जमा कर दिया गए" @@ -1575,13 +1639,13 @@ msgstr "समीक्षा समाप्त" msgid "Hidden" msgstr "गुप्त" -#: funnel/models/comment.py:49 funnel/models/moderation.py:17 +#: funnel/models/comment.py:49 funnel/models/moderation.py:19 msgid "Spam" msgstr "स्पैम" -#: funnel/models/comment.py:51 funnel/models/project.py:55 -#: funnel/models/proposal.py:54 funnel/models/update.py:41 -#: funnel/models/user.py:127 +#: funnel/models/comment.py:51 funnel/models/project.py:54 +#: funnel/models/proposal.py:52 funnel/models/update.py:41 +#: funnel/models/user.py:128 msgid "Deleted" msgstr "मिटाए हुए" @@ -1589,390 +1653,383 @@ msgstr "मिटाए हुए" msgid "Verified" msgstr "सत्यापित" -#: funnel/models/comment.py:69 funnel/models/user.py:1027 +#: funnel/models/comment.py:69 funnel/models/user.py:1053 msgid "[deleted]" msgstr "[मिटाया हुआ]" -#: funnel/models/comment.py:70 funnel/models/user.py:1028 +#: funnel/models/comment.py:70 funnel/models/phone_number.py:420 +#: funnel/models/user.py:1054 msgid "[removed]" msgstr "[हटाए हुए]" -#: funnel/models/comment.py:342 +#: funnel/models/comment.py:349 msgid "{user} commented on {obj}" msgstr "{user} ने {obj} पर कमेंट किया" -#: funnel/models/comment.py:345 +#: funnel/models/comment.py:352 msgid "{user} commented" msgstr "{user} ने कमेंट किया" -#: funnel/models/comment.py:358 +#: funnel/models/comment.py:365 msgid "Submitter" msgstr "" -#: funnel/models/comment.py:361 +#: funnel/models/comment.py:368 msgid "Editor & Promoter" msgstr "" -#: funnel/models/membership_mixin.py:63 +#: funnel/models/membership_mixin.py:65 msgid "Invite" msgstr "आमंत्रण" -#: funnel/models/membership_mixin.py:65 +#: funnel/models/membership_mixin.py:69 msgid "Direct add" msgstr "सीधा जोड़ें" -#: funnel/models/membership_mixin.py:66 +#: funnel/models/membership_mixin.py:71 msgid "Amend" msgstr "संसोधन" -#: funnel/models/moderation.py:16 +#: funnel/models/moderation.py:18 msgid "Not spam" msgstr "गैर स्पैम" -#: funnel/models/notification.py:148 +#: funnel/models/notification.py:162 msgid "Uncategorized" msgstr "अवर्गीकृत" -#: funnel/models/notification.py:149 funnel/templates/account.html.jinja2:5 +#: funnel/models/notification.py:163 funnel/templates/account.html.jinja2:5 #: funnel/templates/account_saved.html.jinja2:4 #: funnel/templates/js/badge.js.jinja2:96 #: funnel/templates/notification_preferences.html.jinja2:5 msgid "My account" msgstr "मेरा अकाउंट" -#: funnel/models/notification.py:151 +#: funnel/models/notification.py:165 msgid "My subscriptions and billing" msgstr "मेरे सदस्यता और बिल" -#: funnel/models/notification.py:155 +#: funnel/models/notification.py:169 msgid "Projects I am participating in" msgstr "मेरे द्वारा भाग लिए प्रोजेक्ट" -#: funnel/models/notification.py:166 +#: funnel/models/notification.py:180 msgid "Projects I am a crew member in" msgstr "प्रोजेक्ट, जिनमें मैं दल का हिस्सा हूं" -#: funnel/models/notification.py:174 -msgid "Organizations I manage" -msgstr "मेरे द्वारा प्रबंधित संगठन" +#: funnel/models/notification.py:188 +msgid "Accounts I manage" +msgstr "" -#: funnel/models/notification.py:182 +#: funnel/models/notification.py:196 msgid "As a website administrator" msgstr "वेबसाइट एडमिनिस्ट्रेटर के तौर पर" -#: funnel/models/notification.py:195 +#: funnel/models/notification.py:209 msgid "Queued" msgstr "श्रेणीबद्ध" -#: funnel/models/notification.py:196 +#: funnel/models/notification.py:210 msgid "Pending" msgstr "लंबित" -#: funnel/models/notification.py:197 +#: funnel/models/notification.py:211 msgid "Delivered" msgstr "पहुंचाए हुए" -#: funnel/models/notification.py:198 +#: funnel/models/notification.py:212 msgid "Failed" msgstr "विफल रहे" -#: funnel/models/notification.py:199 +#: funnel/models/notification.py:213 #: funnel/templates/auth_client.html.jinja2:92 msgid "Unknown" msgstr "अज्ञात" -#: funnel/models/notification.py:260 +#: funnel/models/notification.py:310 msgid "Unspecified notification type" msgstr "अनिर्धारित नोटिफिकेशन प्रकार" -#: funnel/models/notification_types.py:81 +#: funnel/models/notification_types.py:83 msgid "When my account password changes" msgstr "जब मेरे अकाउंट का पासवर्ड बदला जाए" -#: funnel/models/notification_types.py:82 +#: funnel/models/notification_types.py:84 msgid "For your safety, in case this was not authorized" msgstr "आपकी सुरक्षा के लिए, यदि यह अधिकृत न हो तो" -#: funnel/models/notification_types.py:98 +#: funnel/models/notification_types.py:101 msgid "When I register for a project" msgstr "जब मैं किसी प्रोजेक्ट के लिए पंजीकृत होऊं" -#: funnel/models/notification_types.py:99 +#: funnel/models/notification_types.py:102 msgid "This will prompt a calendar entry in Gmail and other apps" msgstr "यह Gmail तथा अन्य एप्स में कैलेंडर एंट्री के लिए सूचना देगा" -#: funnel/models/notification_types.py:112 -msgid "When I cancel my registration" -msgstr "जब मैं अपना पंजीकरण रद्द करूं" - -#: funnel/models/notification_types.py:113 -#: funnel/models/notification_types.py:145 -msgid "Confirmation for your records" -msgstr "आपके रिकॉर्ड के लिए पुष्टि" - -#: funnel/models/notification_types.py:128 +#: funnel/models/notification_types.py:129 msgid "When a project posts an update" msgstr "जब किसी प्रोजेक्ट की कुछ खबर दी जाए" -#: funnel/models/notification_types.py:129 +#: funnel/models/notification_types.py:130 msgid "Typically contains critical information such as video conference links" msgstr "खासतौर से महत्वपूर्ण जानकारी होते हैं जैसे कि वीडियो कॉन्फ्रेंस के लिंक" -#: funnel/models/notification_types.py:144 +#: funnel/models/notification_types.py:145 msgid "When I submit a proposal" msgstr "जब मैं कोई प्रस्ताव भेजूं" -#: funnel/models/notification_types.py:165 +#: funnel/models/notification_types.py:146 +msgid "Confirmation for your records" +msgstr "आपके रिकॉर्ड के लिए पुष्टि" + +#: funnel/models/notification_types.py:166 msgid "When a project I’ve registered for is about to start" msgstr "जब मेरे द्वारा पंजीकृत कोई प्रोजेक्ट शुरू होने वाला हो" -#: funnel/models/notification_types.py:166 +#: funnel/models/notification_types.py:167 msgid "You will be notified 5-10 minutes before the starting time" msgstr "आपको शुरू होने के 5-10 मिनट पहले सूचित किया जाएगा" -#: funnel/models/notification_types.py:183 -msgid "When there is a new comment on a project or proposal I’m in" -msgstr "जब मेरे द्वारा शामिल किसी प्रोजेक्ट या प्रस्ताव में कोई नई कमेंट की जाए" - -#: funnel/models/notification_types.py:197 -msgid "When someone replies to my comment" -msgstr "जब कोई मेरे मेरे कमेंट का जवाब दे" +#: funnel/models/notification_types.py:182 +msgid "When there is a new comment on something I’m involved in" +msgstr "" -#: funnel/models/notification_types.py:215 -msgid "When a project crew member is added, or roles change" -msgstr "जब प्रोजेक्ट के दल में किसी सदस्य को जोड़ा जाए या भूमिका बदली जाए" +#: funnel/models/notification_types.py:194 +msgid "When someone replies to my comment or mentions me" +msgstr "" -#: funnel/models/notification_types.py:216 -msgid "Crew members have access to the project’s controls" -msgstr "दल के सदस्य के पास प्रोजेक्ट के नियंत्रणों को बदलने का एक्सेस होता है" +#: funnel/models/notification_types.py:211 +msgid "When a project crew member is added or removed" +msgstr "" -#: funnel/models/notification_types.py:231 -msgid "When a project crew member is removed, including me" -msgstr "मेरे सहित, जब प्रोजेक्ट के दल के किसी सदस्य को हटाया जाए" +#: funnel/models/notification_types.py:212 +msgid "Crew members have access to the project’s settings and data" +msgstr "" -#: funnel/models/notification_types.py:245 +#: funnel/models/notification_types.py:240 msgid "When my project receives a new proposal" msgstr "जब मेरे प्रोजेक्ट में कोई नया प्रस्ताव आए" -#: funnel/models/notification_types.py:260 +#: funnel/models/notification_types.py:256 msgid "When someone registers for my project" msgstr "जब कोई मेरे प्रोजेक्ट के लिए पंजीकृत हो" -#: funnel/models/notification_types.py:277 -msgid "When organization admins change" -msgstr "जब संगठन का कोई एडमिन बदले" - -#: funnel/models/notification_types.py:278 -msgid "Organization admins control all projects under the organization" -msgstr "संगठन के एडमिन उसमें मौजूद सभी प्रोजेक्ट का नियंत्रण रखते हैं" +#: funnel/models/notification_types.py:273 +msgid "When account admins change" +msgstr "" -#: funnel/models/notification_types.py:292 -msgid "When an organization admin is removed, including me" -msgstr "मेरे सहित, जब संगठन के किसी एडमिन को हटाया जाए" +#: funnel/models/notification_types.py:274 +msgid "Account admins control all projects under the account" +msgstr "" -#: funnel/models/notification_types.py:309 +#: funnel/models/notification_types.py:303 msgid "When a comment is reported as spam" msgstr "जब किसी कमेंट को स्पैम जैसा रिपोर्ट किया जाए" -#: funnel/models/profile.py:43 +#: funnel/models/phone_number.py:419 +msgid "[blocked]" +msgstr "" + +#: funnel/models/profile.py:47 msgid "Autogenerated" msgstr "स्वचालित" -#: funnel/models/profile.py:44 funnel/models/project.py:62 +#: funnel/models/profile.py:48 funnel/models/project.py:61 #: funnel/models/update.py:45 funnel/templates/auth_client.html.jinja2:44 msgid "Public" msgstr "सार्वजनिक" -#: funnel/models/profile.py:45 +#: funnel/models/profile.py:49 #: funnel/templates/organization_teams.html.jinja2:19 msgid "Private" msgstr "निजी" -#: funnel/models/profile.py:462 funnel/templates/macros.html.jinja2:490 +#: funnel/models/profile.py:485 funnel/templates/profile_layout.html.jinja2:90 msgid "Make public" msgstr "सार्वजनिक बनाएं" -#: funnel/models/profile.py:473 funnel/templates/macros.html.jinja2:427 +#: funnel/models/profile.py:496 funnel/templates/profile_layout.html.jinja2:27 msgid "Make private" msgstr "निजी बनाएं" -#: funnel/models/project.py:52 funnel/models/project.py:400 -#: funnel/models/proposal.py:46 funnel/models/proposal.py:294 +#: funnel/models/project.py:51 funnel/models/project.py:396 +#: funnel/models/proposal.py:44 funnel/models/proposal.py:297 #: funnel/models/update.py:39 funnel/templates/js/update.js.jinja2:5 #: funnel/templates/js/update.js.jinja2:30 msgid "Draft" msgstr "ड्राफ्ट" -#: funnel/models/project.py:53 funnel/models/update.py:40 +#: funnel/models/project.py:52 funnel/models/update.py:40 msgid "Published" msgstr "प्रकाशित" -#: funnel/models/project.py:54 funnel/models/update.py:268 +#: funnel/models/project.py:53 funnel/models/update.py:269 msgid "Withdrawn" msgstr "निवर्तित" -#: funnel/models/project.py:61 +#: funnel/models/project.py:60 msgid "None" msgstr "कोई नहीं" -#: funnel/models/project.py:63 +#: funnel/models/project.py:62 msgid "Closed" msgstr "ख़त्म हो चुके" -#: funnel/models/project.py:347 +#: funnel/models/project.py:343 msgid "Past" msgstr "गुज़रे हुए" -#: funnel/models/project.py:360 +#: funnel/models/project.py:356 funnel/templates/macros.html.jinja2:240 msgid "Live" msgstr "लाइव" -#: funnel/models/project.py:367 funnel/templates/macros.html.jinja2:336 +#: funnel/models/project.py:363 funnel/templates/macros.html.jinja2:326 msgid "Upcoming" msgstr "आने वाले" -#: funnel/models/project.py:374 +#: funnel/models/project.py:370 msgid "Published without sessions" msgstr "बिना सेशन के प्रकाशित कर दिया गया" -#: funnel/models/project.py:383 +#: funnel/models/project.py:379 msgid "Has submissions" msgstr "" -#: funnel/models/project.py:391 +#: funnel/models/project.py:387 msgid "Has sessions" msgstr "जिनमें सेशन हों" -#: funnel/models/project.py:419 +#: funnel/models/project.py:415 msgid "Expired" msgstr "अवधि समाप्त" -#: funnel/models/project.py:455 +#: funnel/models/project.py:451 msgid "Enable submissions" msgstr "" -#: funnel/models/project.py:456 +#: funnel/models/project.py:452 msgid "Submissions will be accepted until the optional closing date" msgstr "" -#: funnel/models/project.py:471 +#: funnel/models/project.py:468 msgid "Disable submissions" msgstr "" -#: funnel/models/project.py:472 +#: funnel/models/project.py:469 msgid "Submissions will no longer be accepted" msgstr "" -#: funnel/models/project.py:482 +#: funnel/models/project.py:479 msgid "Publish project" msgstr "प्रोजेक्ट प्रकाशित करें" -#: funnel/models/project.py:483 +#: funnel/models/project.py:480 msgid "The project has been published" msgstr "प्रोजेक्ट को प्रकाशित कर दिया गया" -#: funnel/models/project.py:499 +#: funnel/models/project.py:496 msgid "Withdraw project" msgstr "प्रोजेक्ट को निवर्तित करें" -#: funnel/models/project.py:500 +#: funnel/models/project.py:497 msgid "The project has been withdrawn and is no longer listed" msgstr "प्रोजेक्ट को निवर्तित कर दिया गया और अब सूचीबद्ध नहीं है" -#: funnel/models/proposal.py:48 +#: funnel/models/proposal.py:46 msgid "Confirmed" msgstr "पुष्टि की गई" -#: funnel/models/proposal.py:49 +#: funnel/models/proposal.py:47 msgid "Waitlisted" msgstr "लंबित" -#: funnel/models/proposal.py:50 +#: funnel/models/proposal.py:48 msgid "Rejected" msgstr "ख़ारिज की गई" -#: funnel/models/proposal.py:51 +#: funnel/models/proposal.py:49 msgid "Cancelled" msgstr "रद्द की गई" -#: funnel/models/proposal.py:52 funnel/models/proposal.py:395 +#: funnel/models/proposal.py:50 funnel/models/proposal.py:398 msgid "Awaiting details" msgstr "विवरण लंबित" -#: funnel/models/proposal.py:53 funnel/models/proposal.py:406 +#: funnel/models/proposal.py:51 funnel/models/proposal.py:409 msgid "Under evaluation" msgstr "मूल्यांकन जारी" -#: funnel/models/proposal.py:57 +#: funnel/models/proposal.py:55 msgid "Shortlisted" msgstr "चयनित" -#: funnel/models/proposal.py:61 +#: funnel/models/proposal.py:59 msgid "Shortlisted for rehearsal" msgstr "रिहर्सल के लिए चयनित" -#: funnel/models/proposal.py:63 +#: funnel/models/proposal.py:61 msgid "Rehearsal ongoing" msgstr "रिहर्सल जारी" -#: funnel/models/proposal.py:287 +#: funnel/models/proposal.py:290 msgid "Confirmed & scheduled" msgstr "पुष्टि और अनुसूचित की गई" -#: funnel/models/proposal.py:295 +#: funnel/models/proposal.py:298 msgid "This proposal has been withdrawn" msgstr "इस प्रस्ताव को निवर्तित कर दिया गया" -#: funnel/models/proposal.py:305 funnel/templates/forms.html.jinja2:183 -#: funnel/templates/submission_form.html.jinja2:45 -#: funnel/templates/submission_form.html.jinja2:48 +#: funnel/models/proposal.py:308 funnel/templates/forms.html.jinja2:190 +#: funnel/templates/project_cfp.html.jinja2:52 +#: funnel/templates/submission_form.html.jinja2:40 +#: funnel/templates/submission_form.html.jinja2:42 msgid "Submit" msgstr "जमा करें" -#: funnel/models/proposal.py:306 funnel/models/proposal.py:319 +#: funnel/models/proposal.py:309 funnel/models/proposal.py:322 msgid "This proposal has been submitted" msgstr "इस प्रस्ताव को जमा कर दिया गया" -#: funnel/models/proposal.py:318 +#: funnel/models/proposal.py:321 msgid "Send Back to Submitted" msgstr "जमा किए पर वापस भेजें" -#: funnel/models/proposal.py:329 -#: funnel/templates/project_layout.html.jinja2:150 -#: funnel/views/account_reset.py:176 funnel/views/comment.py:445 -#: funnel/views/login.py:110 funnel/views/login_session.py:689 +#: funnel/models/proposal.py:332 +#: funnel/templates/project_layout.html.jinja2:145 +#: funnel/views/account_reset.py:178 funnel/views/comment.py:445 +#: funnel/views/login.py:110 funnel/views/login_session.py:693 msgid "Confirm" msgstr "स्वीकृती दें" -#: funnel/models/proposal.py:330 +#: funnel/models/proposal.py:333 msgid "This proposal has been confirmed" msgstr "इस प्रस्ताव को स्वीकृति दे दी गई" -#: funnel/models/proposal.py:340 +#: funnel/models/proposal.py:343 msgid "Unconfirm" msgstr "स्वीकृति वापस लें" -#: funnel/models/proposal.py:341 +#: funnel/models/proposal.py:344 msgid "This proposal is no longer confirmed" msgstr "इस प्रस्ताव को अब स्वीकृति प्राप्त नहीं है" -#: funnel/models/proposal.py:351 +#: funnel/models/proposal.py:354 msgid "Waitlist" msgstr "प्रतीक्षा-सूची" -#: funnel/models/proposal.py:352 +#: funnel/models/proposal.py:355 msgid "This proposal has been waitlisted" msgstr "इस प्रस्ताव को प्रतीक्षा-सूची में डाल दिया गया है" -#: funnel/models/proposal.py:362 +#: funnel/models/proposal.py:365 msgid "Reject" msgstr "ख़ारिज करें" -#: funnel/models/proposal.py:363 +#: funnel/models/proposal.py:366 msgid "This proposal has been rejected" msgstr "इस प्रस्ताव को ख़ारिज कर दिया गया" -#: funnel/models/proposal.py:373 funnel/templates/delete.html.jinja2:12 +#: funnel/models/proposal.py:376 funnel/templates/delete.html.jinja2:12 #: funnel/templates/forms.html.jinja2:150 #: funnel/templates/js/membership.js.jinja2:76 #: funnel/templates/otpform.html.jinja2:10 @@ -1980,50 +2037,52 @@ msgstr "इस प्रस्ताव को ख़ारिज कर दि msgid "Cancel" msgstr "रद्द करें" -#: funnel/models/proposal.py:374 +#: funnel/models/proposal.py:377 msgid "This proposal has been cancelled" msgstr "इस प्रस्ताव को रद्द कर दिया गया" -#: funnel/models/proposal.py:384 +#: funnel/models/proposal.py:387 msgid "Undo cancel" msgstr "रद्द किए को स्वीकारें" -#: funnel/models/proposal.py:385 -msgid "This proposal's cancellation has been reversed" +#: funnel/models/proposal.py:388 +msgid "This proposal’s cancellation has been reversed" msgstr "इस प्रस्ताव को रद्द किए से स्वीकारा जा चुका है" -#: funnel/models/proposal.py:396 +#: funnel/models/proposal.py:399 msgid "Awaiting details for this proposal" msgstr "इस प्रस्ताव की जानकारी का इंतज़ार है" -#: funnel/models/proposal.py:407 +#: funnel/models/proposal.py:410 msgid "This proposal has been put under evaluation" msgstr "इस प्रस्ताव को मूल्यांकन के लिए भेजा गया है" -#: funnel/models/proposal.py:417 funnel/templates/auth_client.html.jinja2:17 +#: funnel/models/proposal.py:420 funnel/templates/auth_client.html.jinja2:17 #: funnel/templates/auth_client.html.jinja2:170 #: funnel/templates/delete.html.jinja2:11 #: funnel/templates/js/comments.js.jinja2:89 -#: funnel/templates/labels.html.jinja2:52 +#: funnel/templates/labels.html.jinja2:85 #: funnel/templates/organization_teams.html.jinja2:42 -#: funnel/templates/submission.html.jinja2:62 funnel/views/comment.py:400 -#: funnel/views/label.py:258 funnel/views/update.py:186 +#: funnel/templates/submission.html.jinja2:134 +#: funnel/templates/venues.html.jinja2:26 +#: funnel/templates/venues.html.jinja2:58 funnel/views/comment.py:400 +#: funnel/views/label.py:259 funnel/views/update.py:186 msgid "Delete" msgstr "मिटाएं" -#: funnel/models/proposal.py:418 +#: funnel/models/proposal.py:421 msgid "This proposal has been deleted" msgstr "इस प्रस्ताव को मिटा दिया गया" -#: funnel/models/rsvp.py:29 funnel/models/rsvp.py:94 +#: funnel/models/rsvp.py:29 funnel/models/rsvp.py:95 msgid "Going" msgstr "शामिल होंगे" -#: funnel/models/rsvp.py:30 funnel/models/rsvp.py:105 +#: funnel/models/rsvp.py:30 funnel/models/rsvp.py:106 msgid "Not going" msgstr "शामिल नहीं होंगे" -#: funnel/models/rsvp.py:31 funnel/models/rsvp.py:116 +#: funnel/models/rsvp.py:31 funnel/models/rsvp.py:117 msgid "Maybe" msgstr "शायद" @@ -2031,7 +2090,7 @@ msgstr "शायद" msgid "Awaiting" msgstr "प्रतीक्षित" -#: funnel/models/rsvp.py:95 funnel/models/rsvp.py:106 funnel/models/rsvp.py:117 +#: funnel/models/rsvp.py:96 funnel/models/rsvp.py:107 funnel/models/rsvp.py:118 msgid "Your response has been saved" msgstr "आपकी प्रतिक्रिया सेव कर दी गई" @@ -2039,39 +2098,58 @@ msgstr "आपकी प्रतिक्रिया सेव कर दी msgid "Restricted" msgstr "वर्जित" -#: funnel/models/update.py:260 +#: funnel/models/update.py:261 msgid "Unpublished" msgstr "अप्रकाशित" -#: funnel/models/user.py:119 funnel/models/user.py:134 +#: funnel/models/user.py:120 funnel/models/user.py:135 msgid "Active" msgstr "सक्रिय" -#: funnel/models/user.py:121 funnel/models/user.py:136 +#: funnel/models/user.py:122 funnel/models/user.py:137 msgid "Suspended" msgstr "निलंबित" -#: funnel/models/user.py:123 +#: funnel/models/user.py:124 msgid "Merged" msgstr "संयोजित" -#: funnel/models/user.py:125 +#: funnel/models/user.py:126 msgid "Invited" msgstr "आमंत्रित" -#: funnel/models/video_mixin.py:78 funnel/models/video_mixin.py:89 -msgid "This must be a shareable URL for a single file in Google Drive" +#: funnel/static/js/fullcalendar.packed.js:13965 +#: funnel/static/js/fullcalendar.packed.js:14063 +#: funnel/static/js/fullcalendar.packed.js:14149 +msgid "timeFormat" +msgstr "" + +#: funnel/static/js/fullcalendar.packed.js:13997 +#: funnel/static/js/fullcalendar.packed.js:14085 +msgid "dragOpacity" +msgstr "" + +#: funnel/static/js/fullcalendar.packed.js:13998 +#: funnel/static/js/fullcalendar.packed.js:14086 +msgid "dragRevertDuration" msgstr "" -#: funnel/static/js/ractive.packed.js:11 +#: funnel/static/js/fullcalendar.packed.js:14020 +msgid "defaultEventMinutes" +msgstr "" + +#: funnel/static/js/ractive.packed.js:2257 msgid "${}" msgstr "" -#: funnel/static/js/ractive.packed.js:12 +#: funnel/static/js/ractive.packed.js:5010 msgid "." msgstr "" -#: funnel/static/js/ractive.packed.js:13 +#: funnel/static/js/ractive.packed.js:6767 +#: funnel/static/js/ractive.packed.js:6774 +#: funnel/static/js/ractive.packed.js:6788 +#: funnel/static/js/ractive.packed.js:6809 msgid "@" msgstr "" @@ -2079,76 +2157,79 @@ msgstr "" msgid "The room sequence and colours have been updated" msgstr "" -#: funnel/static/js/schedules.js:124 funnel/static/js/schedules.js:215 -#: funnel/static/js/schedules.js:252 funnel/static/js/schedules.js:462 +#: funnel/static/js/schedules.js:124 funnel/static/js/schedules.js:224 +#: funnel/static/js/schedules.js:267 funnel/static/js/schedules.js:477 #: funnel/static/js/schedules.packed.js:1 msgid "The server could not be reached. Check connection and try again" msgstr "" -#: funnel/static/js/schedules.js:234 funnel/static/js/schedules.packed.js:1 -#: funnel/templates/submission.html.jinja2:82 funnel/views/session.py:70 -#: funnel/views/session.py:119 +#: funnel/static/js/schedules.js:249 funnel/static/js/schedules.packed.js:1 +#: funnel/templates/session_view_popup.html.jinja2:52 +#: funnel/templates/submission.html.jinja2:176 funnel/views/session.py:45 msgid "Edit session" msgstr "सेशन संपादित करें" -#: funnel/static/js/schedules.js:235 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:250 funnel/static/js/schedules.packed.js:1 msgid "Schedule session" msgstr "" -#: funnel/static/js/schedules.js:410 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:425 funnel/static/js/schedules.packed.js:1 msgid "Add new session" msgstr "" -#: funnel/static/js/schedules.js:445 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:460 funnel/static/js/schedules.packed.js:1 #, python-format msgid "Remove %s from the schedule?" msgstr "" -#: funnel/static/js/schedules.js:500 funnel/static/js/schedules.js:669 -#: funnel/static/js/schedules.js:689 funnel/static/js/schedules.packed.js:1 -#: funnel/templates/schedule_edit.html.jinja2:90 -#: funnel/views/organization.py:189 funnel/views/project.py:329 +#: funnel/static/js/schedules.js:515 funnel/static/js/schedules.js:684 +#: funnel/static/js/schedules.js:704 funnel/static/js/schedules.packed.js:1 +#: funnel/templates/schedule_edit.html.jinja2:97 +#: funnel/templates/submission_admin_panel.html.jinja2:39 +#: funnel/templates/submission_form.html.jinja2:40 +#: funnel/templates/submission_form.html.jinja2:42 +#: funnel/views/organization.py:189 funnel/views/project.py:343 #: funnel/views/update.py:158 funnel/views/venue.py:121 #: funnel/views/venue.py:184 msgid "Save" msgstr "सेव करें" -#: funnel/static/js/schedules.js:541 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:556 funnel/static/js/schedules.packed.js:1 msgid "5 mins" msgstr "" -#: funnel/static/js/schedules.js:543 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:558 funnel/static/js/schedules.packed.js:1 msgid "15 mins" msgstr "" -#: funnel/static/js/schedules.js:545 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:560 funnel/static/js/schedules.packed.js:1 msgid "30 mins" msgstr "" -#: funnel/static/js/schedules.js:547 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:562 funnel/static/js/schedules.packed.js:1 msgid "60 mins" msgstr "" -#: funnel/static/js/schedules.js:571 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:586 funnel/static/js/schedules.packed.js:1 msgid "Autosave" msgstr "" -#: funnel/static/js/schedules.js:673 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:688 funnel/static/js/schedules.packed.js:1 msgid "Saving…" msgstr "" -#: funnel/static/js/schedules.js:685 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:700 funnel/static/js/schedules.packed.js:1 msgid "Saved" msgstr "" -#: funnel/static/js/schedules.js:692 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:707 funnel/static/js/schedules.packed.js:1 #, python-format msgid "" "The server could not be reached. There are %d unsaved sessions. Check " "connection and try again" msgstr "" -#: funnel/static/js/schedules.js:699 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:714 funnel/static/js/schedules.packed.js:1 msgid "" "The server could not be reached. There is 1 unsaved session. Check " "connection and try again" @@ -2156,17 +2237,16 @@ msgstr "" #: funnel/templates/about.html.jinja2:2 funnel/templates/about.html.jinja2:13 #: funnel/templates/about.html.jinja2:18 -#: funnel/templates/macros.html.jinja2:552 +#: funnel/templates/macros.html.jinja2:397 msgid "About Hasgeek" msgstr "Hasgeek का परिचय" #: funnel/templates/about.html.jinja2:29 msgid "" -"It’s 2022, and the world as we know it is slightly upturned. Meeting new " -"people and geeking-out about your passion has become harder than it used " -"to be. These special interactions that drive us to do new things and " -"explore new ideas also need a new place. It’s time to rebuild everything." -" Join us." +"In the post-pandemic world, meeting new people and geeking-out about your" +" passion has become harder than it used to be. These special interactions" +" that drive us to do new things and explore new ideas also need a new " +"place. It’s time to rebuild everything. Join us." msgstr "" #: funnel/templates/about.html.jinja2:30 funnel/templates/about.html.jinja2:33 @@ -2207,10 +2287,19 @@ msgstr "" "अपने पसंदीदा समुदायों की सदस्यता लें और किसी भी संवाद या संग काम करने के " "अवसर को हाथ से जाने न दें." +#: funnel/templates/account.html.jinja2:36 +#: funnel/templates/account_menu.html.jinja2:35 +msgid "Add username" +msgstr "" + #: funnel/templates/account.html.jinja2:42 msgid "Go to account" msgstr "अकाउंट पर जाएँ" +#: funnel/templates/account.html.jinja2:51 +msgid "Info" +msgstr "" + #: funnel/templates/account.html.jinja2:63 #: funnel/templates/account_merge.html.jinja2:8 #: funnel/templates/account_merge.html.jinja2:14 @@ -2225,27 +2314,31 @@ msgstr "इस डिवाइस से" #: funnel/templates/account.html.jinja2:90 #: funnel/templates/auth_client.html.jinja2:169 #: funnel/templates/js/comments.js.jinja2:88 -#: funnel/templates/labels.html.jinja2:48 +#: funnel/templates/labels.html.jinja2:75 #: funnel/templates/organization_teams.html.jinja2:41 #: funnel/templates/project_admin.html.jinja2:28 #: funnel/templates/project_admin.html.jinja2:55 #: funnel/templates/project_admin.html.jinja2:77 -#: funnel/templates/submission_form.html.jinja2:16 -#: funnel/templates/submission_form.html.jinja2:44 -#: funnel/templates/ticket_event.html.jinja2:27 +#: funnel/templates/submission_form.html.jinja2:20 +#: funnel/templates/submission_form.html.jinja2:39 +#: funnel/templates/ticket_event.html.jinja2:31 +#: funnel/templates/ticket_type.html.jinja2:15 +#: funnel/templates/venues.html.jinja2:25 +#: funnel/templates/venues.html.jinja2:57 msgid "Edit" msgstr "संपादित करें" -#: funnel/templates/account.html.jinja2:95 funnel/views/account.py:444 +#: funnel/templates/account.html.jinja2:95 funnel/views/account.py:453 msgid "Change password" msgstr "पासवर्ड बदलें" -#: funnel/templates/account.html.jinja2:97 funnel/views/account.py:441 +#: funnel/templates/account.html.jinja2:97 funnel/views/account.py:450 msgid "Set password" msgstr "पासवर्ड सेट करें" #: funnel/templates/account.html.jinja2:103 -#: funnel/templates/account.html.jinja2:346 +#: funnel/templates/account.html.jinja2:357 +#: funnel/templates/account.html.jinja2:358 #: funnel/templates/account_menu.html.jinja2:112 msgid "Logout" msgstr "लॉग आउट करें" @@ -2260,19 +2353,19 @@ msgid "Last used %(last_used_at)s" msgstr "आखरी बार इस्तेमाल %(last_used_at)s" #: funnel/templates/account.html.jinja2:122 -#: funnel/templates/account.html.jinja2:172 -#: funnel/templates/account.html.jinja2:181 -#: funnel/templates/account.html.jinja2:231 +#: funnel/templates/account.html.jinja2:173 +#: funnel/templates/account.html.jinja2:182 +#: funnel/templates/account.html.jinja2:232 #: funnel/templates/collaborator_list.html.jinja2:30 #: funnel/templates/project_sponsor_popup.html.jinja2:22 -#: funnel/views/account.py:592 funnel/views/account.py:735 -#: funnel/views/account.py:767 funnel/views/membership.py:296 -#: funnel/views/membership.py:582 +#: funnel/views/account.py:609 funnel/views/account.py:752 +#: funnel/views/account.py:784 funnel/views/membership.py:289 +#: funnel/views/membership.py:575 msgid "Remove" msgstr "हटाएं" #: funnel/templates/account.html.jinja2:134 -#: funnel/templates/password_login_form.html.jinja2:73 +#: funnel/templates/password_login_form.html.jinja2:75 #, python-format msgid "Login using %(title)s" msgstr "" @@ -2281,22 +2374,26 @@ msgstr "" msgid "Email addresses" msgstr "ईमेल पते" -#: funnel/templates/account.html.jinja2:166 -#: funnel/templates/account.html.jinja2:227 +#: funnel/templates/account.html.jinja2:167 +#: funnel/templates/account.html.jinja2:228 msgid "Primary" msgstr "प्राथमिक" -#: funnel/templates/account.html.jinja2:179 +#: funnel/templates/account.html.jinja2:180 msgid "(pending verification)" msgstr "(वेरीफिकेशन बाकी है)" -#: funnel/templates/account.html.jinja2:194 -#: funnel/templates/account.html.jinja2:245 +#: funnel/templates/account.html.jinja2:193 +msgid "Set as primary email" +msgstr "" + +#: funnel/templates/account.html.jinja2:195 +#: funnel/templates/account.html.jinja2:246 #: funnel/templates/venues.html.jinja2:36 msgid "Set as primary" msgstr "प्राथमिक बनाएं" -#: funnel/templates/account.html.jinja2:199 funnel/views/account.py:496 +#: funnel/templates/account.html.jinja2:199 funnel/views/account.py:505 msgid "Add an email address" msgstr "अन्य ईमेल पता जोड़ें" @@ -2304,76 +2401,84 @@ msgstr "अन्य ईमेल पता जोड़ें" msgid "Mobile numbers" msgstr "मोबाइल नंबर" -#: funnel/templates/account.html.jinja2:249 +#: funnel/templates/account.html.jinja2:250 msgid "Add a mobile number" msgstr "अन्य मोबाइल नंबर जोड़ें" -#: funnel/templates/account.html.jinja2:261 +#: funnel/templates/account.html.jinja2:262 msgid "Connected apps" msgstr "जुड़े हुए ऐप्स" -#: funnel/templates/account.html.jinja2:273 +#: funnel/templates/account.html.jinja2:274 +#: funnel/templates/account.html.jinja2:275 msgid "Made by Hasgeek" msgstr "Hasgeek द्वारा निर्मित" -#: funnel/templates/account.html.jinja2:283 +#: funnel/templates/account.html.jinja2:285 #, python-format msgid "Since %(since)s – last used %(last_used)s" msgstr "%(since)s से स्थापित – आखरी बार इस्तेमाल %(last_used)s" -#: funnel/templates/account.html.jinja2:285 +#: funnel/templates/account.html.jinja2:287 #, python-format msgid "Since %(since)s" msgstr "%(since)s से स्थापित" -#: funnel/templates/account.html.jinja2:305 +#: funnel/templates/account.html.jinja2:295 +#: funnel/templates/auth_client.html.jinja2:99 funnel/views/auth_client.py:203 +msgid "Disconnect" +msgstr "डिसकनेक्ट करें" + +#: funnel/templates/account.html.jinja2:310 msgid "Login sessions" msgstr "लॉगिन सेशन" -#: funnel/templates/account.html.jinja2:323 +#: funnel/templates/account.html.jinja2:334 #, python-format msgid "%(browser)s on %(device)s" msgstr "" -#: funnel/templates/account.html.jinja2:330 +#: funnel/templates/account.html.jinja2:341 #, python-format msgid "Since %(since)s via %(login_service)s – last active %(last_active)s" msgstr "" "%(login_service)s के ज़रिए %(since)s से – आखरी बार इस्तेमाल " "%(last_active)s" -#: funnel/templates/account.html.jinja2:332 +#: funnel/templates/account.html.jinja2:343 #, python-format msgid "Since %(since)s – last active %(last_active)s" msgstr "%(since)s से – आखरी बार इस्तेमाल %(last_active)s" -#: funnel/templates/account.html.jinja2:336 +#: funnel/templates/account.html.jinja2:347 #, python-format msgid "%(location)s – estimated from %(ipaddr)s" msgstr "%(location)s – %(ipaddr)s से अनुमानित" -#: funnel/templates/account_formlayout.html.jinja2:21 -#: funnel/templates/account_formlayout.html.jinja2:28 -#: funnel/templates/img_upload_formlayout.html.jinja2:10 +#: funnel/templates/account_formlayout.html.jinja2:22 +#: funnel/templates/account_formlayout.html.jinja2:29 +#: funnel/templates/img_upload_formlayout.html.jinja2:9 #: funnel/templates/labels_form.html.jinja2:23 +#: funnel/templates/labels_form.html.jinja2:34 #: funnel/templates/macros.html.jinja2:13 -#: funnel/templates/macros.html.jinja2:415 -#: funnel/templates/macros.html.jinja2:479 +#: funnel/templates/modalajaxform.html.jinja2:5 +#: funnel/templates/profile_layout.html.jinja2:15 +#: funnel/templates/profile_layout.html.jinja2:79 #: funnel/templates/project_cfp.html.jinja2:34 -#: funnel/templates/project_layout.html.jinja2:141 -#: funnel/templates/project_layout.html.jinja2:164 +#: funnel/templates/project_layout.html.jinja2:136 +#: funnel/templates/project_layout.html.jinja2:159 #: funnel/templates/project_sponsor_popup.html.jinja2:7 #: funnel/templates/project_sponsor_popup.html.jinja2:21 -#: funnel/templates/schedule_edit.html.jinja2:99 +#: funnel/templates/schedule_edit.html.jinja2:106 #: funnel/templates/schedule_subscribe.html.jinja2:4 #: funnel/templates/session_view_popup.html.jinja2:4 #: funnel/templates/submission_admin_panel.html.jinja2:7 -#: funnel/templates/submission_form.html.jinja2:108 +#: funnel/templates/submission_form.html.jinja2:120 #: funnel/templates/update_logo_modal.html.jinja2:8 msgid "Close" msgstr "बंद करें" -#: funnel/templates/account_formlayout.html.jinja2:22 +#: funnel/templates/account_formlayout.html.jinja2:23 msgid "" "Cookies are required to login. Please enable cookies in your browser’s " "settings and reload this page" @@ -2381,14 +2486,10 @@ msgstr "" "लॉगिन के लिए कुकीज़ की आवश्यक हैं. कृपया अपने ब्राउज़र के सेटिंग्स में " "जाकर कुकीज़ को सक्षम करें और इस पेज को फिर से लोड करें" -#: funnel/templates/account_menu.html.jinja2:35 -msgid "Add username" -msgstr "" - #: funnel/templates/account_menu.html.jinja2:43 #: funnel/templates/account_menu.html.jinja2:96 #: funnel/templates/account_organizations.html.jinja2:4 -#: funnel/templates/macros.html.jinja2:143 +#: funnel/templates/macros.html.jinja2:133 msgid "Organizations" msgstr "संगठन" @@ -2406,12 +2507,12 @@ msgid "Notification settings" msgstr "" #: funnel/templates/account_menu.html.jinja2:102 -#: funnel/templates/macros.html.jinja2:145 +#: funnel/templates/macros.html.jinja2:135 msgid "Saved projects" msgstr "" #: funnel/templates/account_merge.html.jinja2:3 -#: funnel/templates/account_merge.html.jinja2:49 funnel/views/login.py:632 +#: funnel/templates/account_merge.html.jinja2:49 funnel/views/login.py:643 msgid "Merge accounts" msgstr "अकाउंट जोड़ें" @@ -2452,7 +2553,7 @@ msgstr "बाद में" msgid "Add new organization" msgstr "" -#: funnel/templates/account_organizations.html.jinja2:54 +#: funnel/templates/account_organizations.html.jinja2:59 #: funnel/templates/js/membership.js.jinja2:100 msgid "Admin" msgstr "" @@ -2471,7 +2572,7 @@ msgstr "एडमिन पैनल" msgid "Edit this application" msgstr "इस एप्लीकेशन को संपादित करें" -#: funnel/templates/auth_client.html.jinja2:18 funnel/views/auth_client.py:228 +#: funnel/templates/auth_client.html.jinja2:18 funnel/views/auth_client.py:229 msgid "New access key" msgstr "नया एक्सेस की" @@ -2527,10 +2628,6 @@ msgstr "निर्मित" msgid "Last used" msgstr "अंतिम उपयोग" -#: funnel/templates/auth_client.html.jinja2:99 funnel/views/auth_client.py:202 -msgid "Disconnect" -msgstr "डिसकनेक्ट करें" - #: funnel/templates/auth_client.html.jinja2:110 msgid "Access keys" msgstr "एक्सेस कीज़" @@ -2640,6 +2737,10 @@ msgstr "त्रुटी" msgid "Error URI" msgstr "त्रुटी URI" +#: funnel/templates/badge.html.jinja2:4 +msgid "Badge" +msgstr "" + #: funnel/templates/collaborator_list.html.jinja2:21 msgid "Visible" msgstr "" @@ -2649,7 +2750,8 @@ msgid "Collaborator menu" msgstr "" #: funnel/templates/collaborator_list.html.jinja2:29 -#: funnel/templates/submission_form.html.jinja2:82 +#: funnel/templates/submission_form.html.jinja2:85 +#: funnel/templates/submission_form.html.jinja2:100 msgid "Add collaborator" msgstr "" @@ -2683,6 +2785,10 @@ msgstr "CSV" msgid "Download contacts CSV" msgstr "कॉन्टैक्ट की CSV फाइल डाउनलोड करें" +#: funnel/templates/contacts.html.jinja2:77 +msgid "Download contact" +msgstr "" + #: funnel/templates/delete.html.jinja2:9 #: funnel/templates/project_sponsor_popup.html.jinja2:19 msgid "" @@ -2731,7 +2837,7 @@ msgid "Confirm your email address" msgstr "अपने ईमेल पते की पुष्टि करें" #: funnel/templates/email_login_otp.html.jinja2:7 -#: funnel/templates/login.html.jinja2:17 +#: funnel/templates/login.html.jinja2:21 msgid "Hello!" msgstr "" @@ -2739,53 +2845,34 @@ msgstr "" msgid "This login OTP is valid for 15 minutes." msgstr "" -#: funnel/templates/email_project_crew_membership_add_notification.html.jinja2:4 -#, python-format +#: funnel/templates/email_sudo_otp.html.jinja2:6 msgid "" -"\n" -" %(actor)s has added you to ‘%(project)s’ as a crew member.\n" -" " +"You are about to perform a critical action. This OTP serves as your " +"confirmation to proceed and is valid for 15 minutes." msgstr "" -"\n" -" %(actor)s ने आपको ‘%(project)s’ दल के सदस्य के रूप में जोड़ा है.\n" -" " -#: funnel/templates/email_project_crew_membership_add_notification.html.jinja2:9 -#: funnel/templates/email_project_crew_membership_revoke_notification.html.jinja2:9 -msgid "See all crew members" -msgstr "दल के सभी सदस्यों को देखें" +#: funnel/templates/forms.html.jinja2:66 +msgid "Enter a location" +msgstr "" -#: funnel/templates/email_project_crew_membership_invite_notification.html.jinja2:4 -#, python-format -msgid "" -"\n" -" %(actor)s has invited you to join ‘%(project)s’ as a crew member.\n" -" " +#: funnel/templates/forms.html.jinja2:67 +msgid "Clear location" msgstr "" -"\n" -" %(actor)s ने आपको दल के सदस्य के रूप में ‘%(project)s’ में शामिल होने" -" के लिए आमंत्रित किया है.\n" -" " -#: funnel/templates/email_project_crew_membership_invite_notification.html.jinja2:9 -msgid "Accept or decline invite" -msgstr "आमंत्रण को स्वीकार करें या अस्वीकारें" +#: funnel/templates/forms.html.jinja2:79 +msgid "switch to alphabet keyboard" +msgstr "" -#: funnel/templates/email_project_crew_membership_revoke_notification.html.jinja2:4 -#, python-format -msgid "" -"\n" -" %(actor)s has removed you as a crew member from ‘%(project)s’.\n" -" " +#: funnel/templates/forms.html.jinja2:80 +msgid "switch to numeric keyboard" msgstr "" -"\n" -" %(actor)s ने आपको ‘%(project)s’ के दल के सदस्य से हटा दिया है.\n" -" " -#: funnel/templates/email_sudo_otp.html.jinja2:6 -msgid "" -"You are about to perform a critical action. This OTP serves as your " -"confirmation to proceed and is valid for 15 minutes." +#: funnel/templates/forms.html.jinja2:93 +msgid "Show password" +msgstr "" + +#: funnel/templates/forms.html.jinja2:94 +msgid "Hide password" msgstr "" #: funnel/templates/forms.html.jinja2:154 @@ -2804,7 +2891,8 @@ msgstr "अपने जैसे रुझान वाले व्यक् msgid "Spotlight:" msgstr "सुर्खियां:" -#: funnel/templates/index.html.jinja2:58 funnel/templates/layout.html.jinja2:94 +#: funnel/templates/index.html.jinja2:58 +#: funnel/templates/layout.html.jinja2:118 msgid "What’s this about?" msgstr "" @@ -2812,25 +2900,30 @@ msgstr "" msgid "Explore communities" msgstr "" -#: funnel/templates/labels.html.jinja2:10 -#: funnel/templates/project_layout.html.jinja2:229 +#: funnel/templates/label_badge.html.jinja2:4 +msgid "Label badge" +msgstr "" + +#: funnel/templates/labels.html.jinja2:17 +#: funnel/templates/project_layout.html.jinja2:243 #: funnel/templates/submission_admin_panel.html.jinja2:24 msgid "Manage labels" msgstr "लेबल संपादित करें" -#: funnel/templates/labels.html.jinja2:22 +#: funnel/templates/labels.html.jinja2:32 +#: funnel/templates/labels.html.jinja2:34 msgid "Create new label" msgstr "नया लेबल बनाएं" -#: funnel/templates/labels.html.jinja2:44 +#: funnel/templates/labels.html.jinja2:69 msgid "(No labels)" msgstr "(कोई लेबल नहीं है)" -#: funnel/templates/labels.html.jinja2:50 funnel/views/label.py:222 +#: funnel/templates/labels.html.jinja2:80 funnel/views/label.py:222 msgid "Archive" msgstr "पुरालेख" -#: funnel/templates/labels.html.jinja2:59 +#: funnel/templates/labels.html.jinja2:99 msgid "Save label sequence" msgstr "" @@ -2839,364 +2932,293 @@ msgstr "" msgid "Please review the indicated issues" msgstr "कृपया दर्शाए गए समस्याओं की समीक्षा करें" +#: funnel/templates/labels_form.html.jinja2:51 +msgid "Add option" +msgstr "" + #: funnel/templates/labels_form.html.jinja2:66 +#: funnel/templates/submission_form.html.jinja2:64 +#: funnel/templates/submission_form.html.jinja2:79 msgid "Done" msgstr "" -#: funnel/templates/layout.html.jinja2:99 -msgid "Search the site" -msgstr "साइट पर खोजें" +#: funnel/templates/layout.html.jinja2:112 +#: funnel/templates/layout.html.jinja2:115 +#: funnel/templates/layout.html.jinja2:122 +#: funnel/templates/layout.html.jinja2:127 +#: funnel/templates/layout.html.jinja2:130 +#: funnel/templates/layout.html.jinja2:131 +#: funnel/templates/profile_layout.html.jinja2:136 +msgid "Home" +msgstr "मुखपृष्ठ" + +#: funnel/templates/layout.html.jinja2:137 +msgid "Search this site" +msgstr "" -#: funnel/templates/layout.html.jinja2:99 +#: funnel/templates/layout.html.jinja2:138 msgid "Search…" msgstr "खोजें…" -#: funnel/templates/layout.html.jinja2:104 +#: funnel/templates/layout.html.jinja2:149 +#: funnel/templates/layout.html.jinja2:150 #: funnel/templates/search.html.jinja2:7 funnel/templates/search.html.jinja2:8 +#: funnel/templates/siteadmin_comments.html.jinja2:17 +#: funnel/templates/ticket_event.html.jinja2:39 msgid "Search" msgstr "खोजें" -#: funnel/templates/layout.html.jinja2:106 -#: funnel/templates/layout.html.jinja2:119 +#: funnel/templates/layout.html.jinja2:155 +#: funnel/templates/layout.html.jinja2:156 +#: funnel/templates/layout.html.jinja2:184 #: funnel/templates/notification_feed.html.jinja2:5 -#: funnel/templates/project_layout.html.jinja2:450 -#: funnel/templates/project_updates.html.jinja2:9 funnel/views/search.py:499 +#: funnel/templates/project_layout.html.jinja2:456 +#: funnel/templates/project_updates.html.jinja2:9 funnel/views/search.py:511 msgid "Updates" msgstr "अपडेट" -#: funnel/templates/layout.html.jinja2:108 -#: funnel/templates/layout.html.jinja2:121 +#: funnel/templates/layout.html.jinja2:165 +#: funnel/templates/layout.html.jinja2:192 #: funnel/templates/project_comments.html.jinja2:9 -#: funnel/templates/project_layout.html.jinja2:451 -#: funnel/templates/submission.html.jinja2:251 funnel/views/search.py:554 -#: funnel/views/siteadmin.py:255 +#: funnel/templates/project_layout.html.jinja2:457 +#: funnel/templates/submission.html.jinja2:411 funnel/views/search.py:570 +#: funnel/views/siteadmin.py:296 msgid "Comments" msgstr "कमेंट" -#: funnel/templates/layout.html.jinja2:111 -#: funnel/templates/layout.html.jinja2:124 -#: funnel/templates/macros.html.jinja2:406 +#: funnel/templates/layout.html.jinja2:171 +#: funnel/templates/layout.html.jinja2:204 +#: funnel/templates/profile_layout.html.jinja2:6 msgid "Account menu" msgstr "" -#: funnel/templates/layout.html.jinja2:140 -#: funnel/templates/login.html.jinja2:27 funnel/views/login.py:318 +#: funnel/templates/layout.html.jinja2:222 +#: funnel/templates/login.html.jinja2:31 funnel/views/login.py:328 msgid "Login" msgstr "लॉगिन" -#: funnel/templates/login.html.jinja2:18 +#: funnel/templates/login.html.jinja2:22 msgid "Tell us where you’d like to get updates. We’ll send an OTP to confirm." msgstr "" -#: funnel/templates/login.html.jinja2:22 +#: funnel/templates/login.html.jinja2:26 msgid "Or, use your existing account, no OTP required" msgstr "" +#: funnel/templates/login_beacon.html.jinja2:4 +msgid "Login beacon" +msgstr "" + #: funnel/templates/logout_browser_data.html.jinja2:5 #: funnel/templates/logout_browser_data.html.jinja2:27 msgid "Logging out…" msgstr "लॉग आउट हो रहा है…" -#: funnel/templates/macros.html.jinja2:81 +#: funnel/templates/macros.html.jinja2:71 msgid "Login to save this project" msgstr "" -#: funnel/templates/macros.html.jinja2:84 +#: funnel/templates/macros.html.jinja2:74 msgid "Save this project" msgstr "" -#: funnel/templates/macros.html.jinja2:86 +#: funnel/templates/macros.html.jinja2:76 msgid "Unsave this project" msgstr "" -#: funnel/templates/macros.html.jinja2:95 +#: funnel/templates/macros.html.jinja2:85 msgid "Copy link" msgstr "लिंक कॉपी करें" -#: funnel/templates/macros.html.jinja2:98 +#: funnel/templates/macros.html.jinja2:88 msgid "Facebook" msgstr "Facebook" -#: funnel/templates/macros.html.jinja2:110 -#: funnel/templates/project_layout.html.jinja2:74 -#: funnel/templates/project_layout.html.jinja2:90 +#: funnel/templates/macros.html.jinja2:100 +#: funnel/templates/project_layout.html.jinja2:79 +#: funnel/templates/project_layout.html.jinja2:95 msgid "Preview video" msgstr "प्रीव्यू वीडियो" -#: funnel/templates/macros.html.jinja2:121 +#: funnel/templates/macros.html.jinja2:107 +msgid "Powered by VideoKen" +msgstr "" + +#: funnel/templates/macros.html.jinja2:111 msgid "Edit submission video" msgstr "सबमिशन से जुड़ा वीडियो संपादित करें" -#: funnel/templates/macros.html.jinja2:123 -#: funnel/templates/submission.html.jinja2:83 +#: funnel/templates/macros.html.jinja2:113 +#: funnel/templates/submission.html.jinja2:183 msgid "Edit session video" msgstr "सेशन का वीडियो संपादित करें" #: funnel/templates/js/comments.js.jinja2:81 -#: funnel/templates/macros.html.jinja2:132 -#: funnel/templates/macros.html.jinja2:134 -#: funnel/templates/project_layout.html.jinja2:209 +#: funnel/templates/macros.html.jinja2:122 +#: funnel/templates/macros.html.jinja2:124 +#: funnel/templates/project_layout.html.jinja2:197 #: funnel/templates/session_view_popup.html.jinja2:25 -#: funnel/templates/submission.html.jinja2:25 +#: funnel/templates/submission.html.jinja2:33 +#: funnel/templates/submission.html.jinja2:44 msgid "Share" msgstr "साझा करें" -#: funnel/templates/macros.html.jinja2:144 +#: funnel/templates/macros.html.jinja2:134 msgid "Notifications" msgstr "नोटिफिकेशन" -#: funnel/templates/macros.html.jinja2:146 +#: funnel/templates/macros.html.jinja2:136 #: funnel/templates/scan_contact.html.jinja2:5 msgid "Scan badge" msgstr "बैज स्कैन करें" -#: funnel/templates/macros.html.jinja2:147 +#: funnel/templates/macros.html.jinja2:137 msgid "Contacts" msgstr "कॉन्टैक्ट" -#: funnel/templates/macros.html.jinja2:202 -#: funnel/templates/macros.html.jinja2:749 -#: funnel/templates/macros.html.jinja2:776 +#: funnel/templates/macros.html.jinja2:192 +#: funnel/templates/macros.html.jinja2:597 +#: funnel/templates/macros.html.jinja2:624 #, python-format msgid "Accepting submissions till %(date)s" msgstr "" -#: funnel/templates/macros.html.jinja2:228 +#: funnel/templates/macros.html.jinja2:218 msgid "Live schedule" msgstr "लाइव कार्यक्रम" -#: funnel/templates/macros.html.jinja2:230 -#: funnel/templates/macros.html.jinja2:256 -#: funnel/templates/project_layout.html.jinja2:60 +#: funnel/templates/macros.html.jinja2:220 +#: funnel/templates/macros.html.jinja2:246 +#: funnel/templates/project_layout.html.jinja2:63 #: funnel/templates/project_settings.html.jinja2:53 msgid "Livestream" msgstr "लाइवस्ट्रीम" -#: funnel/templates/macros.html.jinja2:232 +#: funnel/templates/macros.html.jinja2:222 msgid "Livestream and schedule" msgstr "लाइवस्ट्रीम और कार्यक्रम" -#: funnel/templates/macros.html.jinja2:252 +#: funnel/templates/macros.html.jinja2:242 #, python-format msgid "Session starts at %(session)s" msgstr "सेशन %(session)s शुरू होगा" -#: funnel/templates/macros.html.jinja2:256 +#: funnel/templates/macros.html.jinja2:246 msgid "Watch livestream" msgstr "लाइवस्ट्रीम देखें" -#: funnel/templates/macros.html.jinja2:259 -#: funnel/templates/project_layout.html.jinja2:456 -#: funnel/templates/project_schedule.html.jinja2:9 -#: funnel/templates/project_schedule.html.jinja2:72 +#: funnel/templates/macros.html.jinja2:249 +#: funnel/templates/project_layout.html.jinja2:462 +#: funnel/templates/project_schedule.html.jinja2:12 +#: funnel/templates/project_schedule.html.jinja2:86 #: funnel/templates/project_settings.html.jinja2:68 #: funnel/templates/schedule_edit.html.jinja2:3 msgid "Schedule" msgstr "कार्यक्रम" -#: funnel/templates/macros.html.jinja2:277 +#: funnel/templates/macros.html.jinja2:267 msgid "Spotlight" msgstr "झलकियां" -#: funnel/templates/macros.html.jinja2:313 +#: funnel/templates/macros.html.jinja2:303 msgid "Learn more" msgstr "" -#: funnel/templates/macros.html.jinja2:359 -#: funnel/templates/macros.html.jinja2:751 +#: funnel/templates/macros.html.jinja2:349 +#: funnel/templates/macros.html.jinja2:599 msgid "Accepting submissions" msgstr "सब्मिशन स्वीकार कर रहे हैं" -#: funnel/templates/macros.html.jinja2:373 -#: funnel/templates/profile.html.jinja2:94 -#: funnel/templates/profile.html.jinja2:116 -#: funnel/templates/profile.html.jinja2:158 -#: funnel/templates/profile.html.jinja2:182 +#: funnel/templates/macros.html.jinja2:363 +#: funnel/templates/profile.html.jinja2:93 +#: funnel/templates/profile.html.jinja2:115 +#: funnel/templates/profile.html.jinja2:157 +#: funnel/templates/profile.html.jinja2:181 msgid "Show more" msgstr "और दिखाएं" -#: funnel/templates/macros.html.jinja2:388 +#: funnel/templates/macros.html.jinja2:378 msgid "All projects" msgstr "सभी प्रोजेक्ट" -#: funnel/templates/macros.html.jinja2:408 -msgid "Manage admins" -msgstr "एडमिंस को प्रबंधित करें" - -#: funnel/templates/macros.html.jinja2:408 -msgid "View admins" -msgstr "एडमिंस को देखें" +#: funnel/templates/macros.html.jinja2:398 +msgid "Team & careers" +msgstr "टीम & करियर" -#: funnel/templates/macros.html.jinja2:409 -msgid "Edit this account" -msgstr "इस अकाउंट को संपादित करें" +#: funnel/templates/macros.html.jinja2:399 +msgid "Contact" +msgstr "संपर्क" -#: funnel/templates/macros.html.jinja2:410 -msgid "Make account private" -msgstr "अकाउंट को निजी बनाएं" +#: funnel/templates/macros.html.jinja2:400 +#: funnel/templates/policy.html.jinja2:19 +msgid "Site policies" +msgstr "साइट नीतियां" -#: funnel/templates/macros.html.jinja2:416 -msgid "Make this account private?" -msgstr "वाकई इस अकाउंट को निजी बनाना चाहते हैं?" +#: funnel/templates/macros.html.jinja2:446 +msgid "(No sessions have been submitted)" +msgstr "(सेशन की कोई जानकारी नहीं दी गई है)" -#: funnel/templates/macros.html.jinja2:420 -msgid "Your account will not be visible to anyone other than you" -msgstr "आपका अकाउंट आपके अलावा किसी और को नहीं दिखाई देगा" +#: funnel/templates/macros.html.jinja2:474 +#: funnel/templates/project_layout.html.jinja2:334 +msgid "Supported by" +msgstr "" -#: funnel/templates/macros.html.jinja2:421 -msgid "It will not be listed in search results" -msgstr "यह खोज परिणामों में भी दिखाई नहीं देगा" - -#: funnel/templates/macros.html.jinja2:422 -msgid "You cannot host projects from this account" -msgstr "आप इस अकाउंट से प्रोजेक्ट होस्ट नहीं कर सकते" - -#: funnel/templates/macros.html.jinja2:423 -msgid "" -"Any existing projects will become inaccessible until the account is " -"public again" -msgstr "" -"जब तक कि इस अकाउंट को दोबारा से सार्वजनिक नहीं बनाया जाता तब तक कोई भी " -"मौजूदा प्रोजेक्ट इसके द्वारा एक्सेस नहीं किया जा सकता" - -#: funnel/templates/macros.html.jinja2:436 -msgid "Back to the account" -msgstr "अकाउंट में वापस जाएं" - -#: funnel/templates/macros.html.jinja2:456 -msgid "Add cover photo url" -msgstr "कवर फोटो का URL जोड़ें" - -#: funnel/templates/macros.html.jinja2:456 -msgid "Add cover photo" -msgstr "कवर फोटो जोड़ें" - -#: funnel/templates/macros.html.jinja2:474 -#: funnel/templates/macros.html.jinja2:522 -#: funnel/templates/profile.html.jinja2:145 -msgid "New project" -msgstr "नया प्रोजेक्ट" - -#: funnel/templates/macros.html.jinja2:476 -#: funnel/templates/macros.html.jinja2:524 -msgid "Make account public" -msgstr "अकाउंट को सार्वजनिक बनाएं" - -#: funnel/templates/macros.html.jinja2:480 -msgid "Make this account public?" -msgstr "इस अकाउंट को सार्वजनिक बनाएं?" - -#: funnel/templates/macros.html.jinja2:484 -msgid "Your account will be visible to anyone visiting the page" -msgstr "आपका अकाउंट इस पेज पर आने वाले सभी व्यक्तियों को दिखाई देगा" - -#: funnel/templates/macros.html.jinja2:485 -msgid "Your account will be listed in search results" -msgstr "आपका अकाउंट खोज प्राणिमों में दिखाई देगा" - -#: funnel/templates/macros.html.jinja2:514 -#, python-format -msgid "Joined %(date)s" -msgstr "%(date)s को जुड़े" +#: funnel/templates/macros.html.jinja2:529 +msgid "Video thumbnail" +msgstr "" #: funnel/templates/macros.html.jinja2:537 -#: funnel/templates/organization_membership.html.jinja2:16 -msgid "Admins" -msgstr "एडमिन" - -#: funnel/templates/macros.html.jinja2:539 -#: funnel/templates/project.html.jinja2:94 funnel/views/search.py:355 -msgid "Sessions" -msgstr "सेशन" - -#: funnel/templates/macros.html.jinja2:540 -#: funnel/templates/user_profile_projects.html.jinja2:6 -#: funnel/views/search.py:193 -msgid "Projects" -msgstr "प्रोजेक्ट" - -#: funnel/templates/macros.html.jinja2:541 -#: funnel/templates/project_layout.html.jinja2:453 -#: funnel/templates/project_settings.html.jinja2:58 -#: funnel/templates/project_submissions.html.jinja2:8 -#: funnel/templates/user_profile_proposals.html.jinja2:6 -#: funnel/views/search.py:412 -msgid "Submissions" -msgstr "सबमिशन" - -#: funnel/templates/macros.html.jinja2:553 -msgid "Team & careers" -msgstr "टीम & करियर" - -#: funnel/templates/macros.html.jinja2:554 -msgid "Contact" -msgstr "संपर्क" - -#: funnel/templates/macros.html.jinja2:555 -#: funnel/templates/policy.html.jinja2:19 -msgid "Site policies" -msgstr "साइट नीतियां" - -#: funnel/templates/macros.html.jinja2:601 -msgid "(No sessions have been submitted)" -msgstr "(सेशन की कोई जानकारी नहीं दी गई है)" - -#: funnel/templates/macros.html.jinja2:629 -#: funnel/templates/project_layout.html.jinja2:343 -msgid "Supported by" -msgstr "" - -#: funnel/templates/macros.html.jinja2:684 -msgid "Video thumbnail" -msgstr "" - -#: funnel/templates/macros.html.jinja2:692 #: funnel/templates/project.html.jinja2:117 #: funnel/templates/project_layout.html.jinja2:34 -#: funnel/templates/project_layout.html.jinja2:338 -#: funnel/templates/project_layout.html.jinja2:371 +#: funnel/templates/project_layout.html.jinja2:329 +#: funnel/templates/project_layout.html.jinja2:373 msgid "more" msgstr "" -#: funnel/templates/macros.html.jinja2:708 +#: funnel/templates/macros.html.jinja2:546 +#, python-format +msgid "%(count)s comment" +msgstr "" + +#: funnel/templates/macros.html.jinja2:556 msgid "This proposal has a preview video" msgstr "इस प्रस्ताव के साथ एक प्रीव्यू वीडियो मौजूद है" -#: funnel/templates/macros.html.jinja2:756 +#: funnel/templates/macros.html.jinja2:604 msgid "Not accepting submissions" msgstr "" -#: funnel/templates/macros.html.jinja2:766 +#: funnel/templates/macros.html.jinja2:614 msgid "Toggle to enable/disable submissions" msgstr "" -#: funnel/templates/macros.html.jinja2:766 +#: funnel/templates/macros.html.jinja2:614 msgid "Open to receive submissions" msgstr "" -#: funnel/templates/macros.html.jinja2:775 +#: funnel/templates/macros.html.jinja2:623 msgid "Make a submission" msgstr "एक सबमिशन भेजें" -#: funnel/templates/macros.html.jinja2:786 +#: funnel/templates/macros.html.jinja2:634 msgid "Past sessions" msgstr "" -#: funnel/templates/macros.html.jinja2:792 +#: funnel/templates/macros.html.jinja2:640 #: funnel/templates/past_projects_section.html.jinja2:3 msgid "Date" msgstr "डेट" -#: funnel/templates/macros.html.jinja2:793 +#: funnel/templates/macros.html.jinja2:641 #: funnel/templates/past_projects_section.html.jinja2:6 msgid "Project" msgstr "प्रोजेक्ट" -#: funnel/templates/macros.html.jinja2:826 +#: funnel/templates/macros.html.jinja2:674 msgid "One project" msgstr "" -#: funnel/templates/macros.html.jinja2:827 +#: funnel/templates/macros.html.jinja2:675 msgid "Explore" msgstr "" @@ -3212,7 +3234,7 @@ msgstr "आपको %(project)s में आमंत्रित किया #: funnel/templates/meta_refresh.html.jinja2:7 #: funnel/templates/meta_refresh.html.jinja2:29 #: funnel/templates/project.html.jinja2:174 -#: funnel/templates/project_layout.html.jinja2:420 +#: funnel/templates/project_layout.html.jinja2:426 #: funnel/templates/redirect.html.jinja2:1 msgid "Loading…" msgstr "लोडिंग…" @@ -3225,7 +3247,7 @@ msgstr "अपठित अपडेट" msgid "To receive timely notifications by SMS, add a phone number" msgstr "SMS के द्वारा समय-समय पर नोटिफिकेशन पाने के लिए, एक फोन नंबर जोड़ें" -#: funnel/templates/notification_preferences.html.jinja2:77 +#: funnel/templates/notification_preferences.html.jinja2:81 msgid "No notifications in this category" msgstr "इस श्रेणी में कोई भी नोटिफिकेशन नहीं है" @@ -3286,6 +3308,7 @@ msgstr "" #: funnel/templates/js/badge.js.jinja2:37 #: funnel/templates/opensearch.xml.jinja2:3 +#: funnel/templates/opensearch.xml.jinja2:7 msgid "Hasgeek" msgstr "Hasgeek" @@ -3293,6 +3316,11 @@ msgstr "Hasgeek" msgid "Search Hasgeek for projects, discussions and more" msgstr "प्रोजेक्ट, चर्चा तथा और भी चीज़ों को Hasgeek पर खोजें" +#: funnel/templates/organization_membership.html.jinja2:13 +#: funnel/templates/profile_layout.html.jinja2:137 +msgid "Admins" +msgstr "एडमिन" + #: funnel/templates/organization_teams.html.jinja2:3 #: funnel/templates/organization_teams.html.jinja2:13 msgid "Teams" @@ -3306,22 +3334,22 @@ msgstr "नया टीम" msgid "Linked apps" msgstr "जुड़े हुए ऐप्स" -#: funnel/templates/password_login_form.html.jinja2:19 -#: funnel/templates/password_login_form.html.jinja2:33 +#: funnel/templates/password_login_form.html.jinja2:20 +#: funnel/templates/password_login_form.html.jinja2:34 msgid "Use OTP" msgstr "" -#: funnel/templates/password_login_form.html.jinja2:22 -#: funnel/templates/password_login_form.html.jinja2:30 +#: funnel/templates/password_login_form.html.jinja2:23 +#: funnel/templates/password_login_form.html.jinja2:31 msgid "Have a password?" msgstr "" -#: funnel/templates/password_login_form.html.jinja2:26 -#: funnel/templates/password_login_form.html.jinja2:37 +#: funnel/templates/password_login_form.html.jinja2:27 +#: funnel/templates/password_login_form.html.jinja2:38 msgid "Forgot password?" msgstr "पासवर्ड भूल गए हैं?" -#: funnel/templates/password_login_form.html.jinja2:62 +#: funnel/templates/password_login_form.html.jinja2:63 #, python-format msgid "" "By signing in, you agree to Hasgeek’s %(project)s starts at %(start_time)s" msgstr "%(project)s शुरू होने का समय %(start_time)s" -#: funnel/templates/notifications/project_starting_email.html.jinja2:12 +#: funnel/templates/notifications/project_starting_email.html.jinja2:11 msgid "Join now" msgstr "अभी जुड़ें" @@ -4334,14 +4541,14 @@ msgstr "अभी जुड़ें" msgid "%(project)s starts at %(start_time)s" msgstr "%(project)s शुरू होने का समय %(start_time)s" -#: funnel/templates/notifications/proposal_received_email.html.jinja2:5 +#: funnel/templates/notifications/proposal_received_email.html.jinja2:4 #, python-format msgid "Your project %(project)s has a new submission: %(proposal)s" msgstr "" "आपके प्रोजेक्ट %(project)s में एक नया सबमिशन आया हैं: " "%(proposal)s" -#: funnel/templates/notifications/proposal_received_email.html.jinja2:7 +#: funnel/templates/notifications/proposal_received_email.html.jinja2:6 msgid "Submission page" msgstr "सबमिशन पेज" @@ -4378,20 +4585,16 @@ msgstr "" "सबमिशन आया है: %(proposal)s %(actor)s की" " ओर से" -#: funnel/templates/notifications/proposal_submitted_email.html.jinja2:5 +#: funnel/templates/notifications/proposal_submitted_email.html.jinja2:4 #, python-format -msgid "" -"You have submitted a new proposal %(proposal)s to the project " -"%(project)s" +msgid "You have submitted %(proposal)s to the project %(project)s" msgstr "" -"आपने %(project)s प्रोजेक्ट में एक नया प्रस्ताव %(proposal)s" -" भेजा है" -#: funnel/templates/notifications/proposal_submitted_email.html.jinja2:7 -msgid "View proposal" -msgstr "प्रस्ताव देखें" +#: funnel/templates/notifications/proposal_submitted_email.html.jinja2:6 +msgid "View submission" +msgstr "" -#: funnel/templates/notifications/proposal_submitted_web.html.jinja2:5 +#: funnel/templates/notifications/proposal_submitted_web.html.jinja2:4 #, python-format msgid "" "You submitted %(proposal)s to %(project)s प्रोजेक्ट में %(proposal)s जमा किया है" -#: funnel/templates/notifications/rsvp_no_email.html.jinja2:5 +#: funnel/templates/notifications/rsvp_no_email.html.jinja2:4 #, python-format msgid "" "You have cancelled your registration for %(project)s. If this was " @@ -4409,42 +4612,42 @@ msgstr "" "आपने %(project)s के लिए अपनी रजिस्ट्रेशन रद्द कर दी है. अगर यह " "गलती से हुआ है, तो फिर आप दोबारा रजिस्टर कर सकते हैं." -#: funnel/templates/notifications/rsvp_no_email.html.jinja2:7 -#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:12 +#: funnel/templates/notifications/rsvp_no_email.html.jinja2:6 +#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:11 msgid "Project page" msgstr "प्रोजेक्ट पेज" -#: funnel/templates/notifications/rsvp_no_web.html.jinja2:5 +#: funnel/templates/notifications/rsvp_no_web.html.jinja2:4 #, python-format msgid "You cancelled your registration for %(project)s" msgstr "" "आपने %(project)s के लिए अपनी रजिस्ट्रेशन रद्द कर " "दी है" -#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:6 +#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:5 #, python-format msgid "You have registered for %(project)s" msgstr "आप %(project)s के लिए रजिस्टर हो चुके हैं" -#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:9 +#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:8 #, python-format msgid "The next session in the schedule starts %(date_and_time)s" msgstr "इस कार्यक्रम का अगला सेशन %(date_and_time)s पर शुरू होगा" -#: funnel/templates/notifications/rsvp_yes_web.html.jinja2:5 +#: funnel/templates/notifications/rsvp_yes_web.html.jinja2:4 #, python-format msgid "You registered for %(project)s" msgstr "आप %(project)s के लिए रजिस्टर हो चुके हैं" -#: funnel/templates/notifications/update_new_email.html.jinja2:5 -#: funnel/templates/notifications/update_new_web.html.jinja2:5 +#: funnel/templates/notifications/update_new_email.html.jinja2:4 +#: funnel/templates/notifications/update_new_web.html.jinja2:4 #, python-format msgid "%(actor)s posted an update in %(project)s:" msgstr "" "%(actor)s ने %(project)s में एक नई " "जानकारी पोस्ट की है:" -#: funnel/templates/notifications/update_new_email.html.jinja2:11 +#: funnel/templates/notifications/update_new_email.html.jinja2:10 msgid "Read on the website" msgstr "वेबसाइट पर जाकर पढ़ें" @@ -4465,9 +4668,9 @@ msgstr "" "करें. यदि और किसी मदद की ज़रूरत हो, तो हमारे सहयोगी दल से संपर्क करें." #: funnel/templates/notifications/user_password_set_email.html.jinja2:13 -#: funnel/views/account_reset.py:111 funnel/views/account_reset.py:288 -#: funnel/views/account_reset.py:290 funnel/views/email.py:45 -#: funnel/views/otp.py:464 +#: funnel/views/account_reset.py:111 funnel/views/account_reset.py:290 +#: funnel/views/account_reset.py:292 funnel/views/email.py:44 +#: funnel/views/otp.py:483 msgid "Reset password" msgstr "पासवर्ड रिसेट करें" @@ -4480,93 +4683,101 @@ msgstr "सहयोगी दल से संपर्क करें" msgid "Your password has been updated" msgstr "आपके पासवर्ड को बदल दिया गया है" -#: funnel/transports/sms/send.py:120 +#: funnel/transports/sms/send.py:53 funnel/transports/sms/send.py:65 +msgid "This phone number is not available" +msgstr "" + +#: funnel/transports/sms/send.py:58 funnel/transports/sms/send.py:215 +msgid "This phone number has been blocked" +msgstr "यह फोन नंबर ब्लॉक कर दिया गया है" + +#: funnel/transports/sms/send.py:61 +msgid "This phone number cannot receive text messages" +msgstr "" + +#: funnel/transports/sms/send.py:149 msgid "Unparseable response from Exotel" msgstr "Exotel की ओर से अजीब प्रतिक्रिया" -#: funnel/transports/sms/send.py:123 +#: funnel/transports/sms/send.py:153 msgid "Exotel API error" msgstr "Exotel API त्रुटी" -#: funnel/transports/sms/send.py:125 +#: funnel/transports/sms/send.py:155 msgid "Exotel not reachable" msgstr "Exotel पहुंच के बाहर है" -#: funnel/transports/sms/send.py:165 +#: funnel/transports/sms/send.py:201 msgid "This phone number is invalid" msgstr "यह फोन नंबर अवैध है" -#: funnel/transports/sms/send.py:169 +#: funnel/transports/sms/send.py:207 msgid "" "Hasgeek cannot send messages to phone numbers in this country.Please " "contact support via email at {email} if this affects youruse of the site" msgstr "" -#: funnel/transports/sms/send.py:177 -msgid "This phone number has been blocked" -msgstr "यह फोन नंबर ब्लॉक कर दिया गया है" - -#: funnel/transports/sms/send.py:182 +#: funnel/transports/sms/send.py:222 msgid "This phone number is unsupported at this time" msgstr "यह फोन नंबर इस समय असमर्थित है" -#: funnel/transports/sms/send.py:190 +#: funnel/transports/sms/send.py:230 msgid "Hasgeek was unable to send a message to this phone number" msgstr "Hasgeek इस फोन नंबर पर मैसेज भेजने में असमर्थ रहा" -#: funnel/transports/sms/send.py:235 +#: funnel/transports/sms/send.py:281 msgid "No service provider available for this recipient" msgstr "इस प्राप्तकर्ता के लिए कोई सेवा प्रदाता उपलब्ध नहीं है" -#: funnel/views/account.py:201 +#: funnel/views/account.py:211 msgid "Unknown browser" msgstr "अज्ञात ब्राउज़र" -#: funnel/views/account.py:228 +#: funnel/views/account.py:238 msgid "Unknown device" msgstr "" -#: funnel/views/account.py:236 funnel/views/account.py:241 +#: funnel/views/account.py:246 funnel/views/account.py:251 msgid "Unknown location" msgstr "अज्ञात स्थान" -#: funnel/views/account.py:252 +#: funnel/views/account.py:262 msgid "Unknown ISP" msgstr "अज्ञात ISP" -#: funnel/views/account.py:340 +#: funnel/views/account.py:350 msgid "Your account has been updated" msgstr "आपकी अकाउंट अपडेट कर दी गई है" -#: funnel/views/account.py:345 +#: funnel/views/account.py:355 msgid "Edit account" msgstr "खाता संपादित करें" -#: funnel/views/account.py:349 funnel/views/auth_client.py:162 -#: funnel/views/auth_client.py:397 funnel/views/auth_client.py:472 -#: funnel/views/profile.py:286 funnel/views/project.py:342 -#: funnel/views/project.py:370 funnel/views/project.py:394 -#: funnel/views/project.py:523 funnel/views/proposal.py:404 -#: funnel/views/ticket_event.py:191 funnel/views/ticket_event.py:278 -#: funnel/views/ticket_event.py:339 funnel/views/ticket_participant.py:211 +#: funnel/views/account.py:359 funnel/views/auth_client.py:163 +#: funnel/views/auth_client.py:398 funnel/views/auth_client.py:473 +#: funnel/views/profile.py:286 funnel/views/project.py:356 +#: funnel/views/project.py:384 funnel/views/project.py:408 +#: funnel/views/project.py:543 funnel/views/proposal.py:416 +#: funnel/views/ticket_event.py:190 funnel/views/ticket_event.py:278 +#: funnel/views/ticket_event.py:340 funnel/views/ticket_participant.py:211 msgid "Save changes" msgstr "परिवर्तनों को सेव करें" -#: funnel/views/account.py:375 +#: funnel/views/account.py:385 msgid "Email address already claimed" msgstr "ईमेल पता पहले ही इस्तेमाल किया जा चुका है" -#: funnel/views/account.py:377 +#: funnel/views/account.py:387 msgid "" "The email address {email} has already been verified by " "another user" msgstr "ईमेल पता {email} अन्य यूजर द्वारा पहले ही वेरिफाई हो चुका है" -#: funnel/views/account.py:384 +#: funnel/views/account.py:394 msgid "Email address already verified" msgstr "ईमेल पता पहले से वेरिफाइड है" -#: funnel/views/account.py:386 +#: funnel/views/account.py:396 msgid "" "Hello, {fullname}! Your email address {email} has already " "been verified" @@ -4574,166 +4785,166 @@ msgstr "" "नमस्ते, {fullname}! आपका ईमेल पता {email} पहले ही वेरिफाई हो" " चुका है" -#: funnel/views/account.py:407 +#: funnel/views/account.py:416 msgid "Email address verified" msgstr "ईमेल पता वेरिफाई किया गया" -#: funnel/views/account.py:409 +#: funnel/views/account.py:418 msgid "" "Hello, {fullname}! Your email address {email} has now been " "verified" msgstr "नमस्ते {fullname}! आपका ईमेल पता {email} अब वेरिफाई हो चुका" -#: funnel/views/account.py:420 +#: funnel/views/account.py:429 msgid "This was not for you" msgstr "यह आपके लिए नहीं था" -#: funnel/views/account.py:421 +#: funnel/views/account.py:430 msgid "" "You’ve opened an email verification link that was meant for another user." " If you are managing multiple accounts, please login with the correct " "account and open the link again" msgstr "आपने एक ईमेल वेरिफिकेशन लिंक खोला है जो किसी अन्य यूजर के लिए था" -#: funnel/views/account.py:429 +#: funnel/views/account.py:438 msgid "Expired confirmation link" msgstr "एक्स्पायर हुई पुष्टि लिंक" -#: funnel/views/account.py:430 +#: funnel/views/account.py:439 msgid "The confirmation link you clicked on is either invalid or has expired" msgstr "आपके द्वारा क्लिक की गई पुष्टि लिंक या तो अमान्य है या एक्स्पायर हो गई है" -#: funnel/views/account.py:457 +#: funnel/views/account.py:466 msgid "Your new password has been saved" msgstr "आपका नया पासवर्ड सेव किया जा चुका है" -#: funnel/views/account.py:491 +#: funnel/views/account.py:500 msgid "We sent you an email to confirm your address" msgstr "आपके पते की पुष्टि के लिए हमने आपको एक ईमेल भेजा है" -#: funnel/views/account.py:498 +#: funnel/views/account.py:507 msgid "Add email" msgstr "ईमेल दर्ज करें" -#: funnel/views/account.py:511 +#: funnel/views/account.py:522 msgid "This is already your primary email address" msgstr "यह पहले से ही आपका प्राथमिक ईमेल पता है" -#: funnel/views/account.py:518 +#: funnel/views/account.py:531 msgid "Your primary email address has been updated" msgstr "आपका प्राथमिक ईमेल पता अपडेट कर दिया गया है" -#: funnel/views/account.py:521 +#: funnel/views/account.py:534 msgid "No such email address is linked to this user account" msgstr "ऐसा कोई भी ईमेल पता इस खाते से नहीं जुड़ा है" -#: funnel/views/account.py:524 +#: funnel/views/account.py:537 msgid "Please select an email address" msgstr "कृपया एक ईमेल पता चुनें" -#: funnel/views/account.py:535 +#: funnel/views/account.py:550 msgid "This is already your primary phone number" msgstr "यह पहले से ही आपका प्राथमिक फोन नंबर है" -#: funnel/views/account.py:542 +#: funnel/views/account.py:559 msgid "Your primary phone number has been updated" msgstr "आपका प्राथमिक फोन नंबर अपडेट कर दिया गया है" -#: funnel/views/account.py:545 +#: funnel/views/account.py:562 msgid "No such phone number is linked to this user account" msgstr "ऐसा कोई फोन नंबर इस खाते से नहीं जुड़ा है" -#: funnel/views/account.py:548 +#: funnel/views/account.py:565 msgid "Please select a phone number" msgstr "कृपया एक फोन नंबर चुनें" -#: funnel/views/account.py:574 +#: funnel/views/account.py:591 msgid "Your account requires at least one verified email address or phone number" msgstr "आपके खाते के लिए कम से कम एक वेरिफाइड ईमेल पता या फोन नंबर की आवश्यकता है" -#: funnel/views/account.py:584 funnel/views/account.py:727 -#: funnel/views/account.py:757 funnel/views/membership.py:292 -#: funnel/views/membership.py:578 +#: funnel/views/account.py:601 funnel/views/account.py:744 +#: funnel/views/account.py:774 funnel/views/membership.py:285 +#: funnel/views/membership.py:571 msgid "Confirm removal" msgstr "हटाने की पुष्टि करें" -#: funnel/views/account.py:585 +#: funnel/views/account.py:602 msgid "Remove email address {email} from your account?" msgstr "अपने खाते से ईमेल पता {email} हटाएं?" -#: funnel/views/account.py:588 +#: funnel/views/account.py:605 msgid "You have removed your email address {email}" msgstr "आपने अपना ईमेल पता {email} हटा दिया है" -#: funnel/views/account.py:619 +#: funnel/views/account.py:636 msgid "This email address is already verified" msgstr "यह ईमेल पता पहले से ही वेरिफाइड है" -#: funnel/views/account.py:635 +#: funnel/views/account.py:652 msgid "The verification email has been sent to this address" msgstr "वेरिफिकेशन ईमेल इस पते पर भेजा गया है" -#: funnel/views/account.py:639 +#: funnel/views/account.py:656 msgid "Resend the verification email?" msgstr "वेरिफिकेशन ईमेल दोबारा भेजना चाहते हैं?" -#: funnel/views/account.py:640 +#: funnel/views/account.py:657 msgid "We will resend the verification email to {email}" msgstr "हम वेरिफिकेशन ईमेल को {email} पर दोबारा भेज देंगे" -#: funnel/views/account.py:644 +#: funnel/views/account.py:661 msgid "Send" msgstr "भेजें" -#: funnel/views/account.py:663 +#: funnel/views/account.py:680 msgid "Add a phone number" msgstr "एक फोन नंबर दर्ज करें" -#: funnel/views/account.py:665 +#: funnel/views/account.py:682 msgid "Verify phone" msgstr "फोन वेरिफाई करें" -#: funnel/views/account.py:676 funnel/views/account_reset.py:157 +#: funnel/views/account.py:693 funnel/views/account_reset.py:157 msgid "This OTP has expired" msgstr "" -#: funnel/views/account.py:696 +#: funnel/views/account.py:712 msgid "Your phone number has been verified" msgstr "आपका फोन नंबर वेरिफाई कर दिया गया है" -#: funnel/views/account.py:702 +#: funnel/views/account.py:718 msgid "This phone number has already been claimed by another user" msgstr "यह फोन नंबर पहले ही किसी अन्य यूजर द्वारा इस्तेमाल किया जा चुका है" -#: funnel/views/account.py:708 +#: funnel/views/account.py:724 msgid "Verify phone number" msgstr "फोन नंबर वेरिफाई करें" -#: funnel/views/account.py:710 +#: funnel/views/account.py:726 msgid "Verify" msgstr "वेरिफाई करें" -#: funnel/views/account.py:728 +#: funnel/views/account.py:745 msgid "Remove phone number {phone} from your account?" msgstr "अपने खाते से फोन नंबर {phone} हटाना चाहते हैं?" -#: funnel/views/account.py:731 +#: funnel/views/account.py:748 msgid "You have removed your number {phone}" msgstr "आपने अपना नंबर {phone} हटा दिया है" -#: funnel/views/account.py:758 +#: funnel/views/account.py:775 msgid "Remove {service} account ‘{username}’ from your account?" msgstr "अपने खाते से {service} खाता ‘{username}’ हटाएं?" -#: funnel/views/account.py:763 +#: funnel/views/account.py:780 msgid "You have removed the {service} account ‘{username}’" msgstr "आपने {service} खाता ‘{username}’ हटा दिया है" -#: funnel/views/account.py:784 +#: funnel/views/account.py:801 msgid "Your account has been deleted" msgstr "" -#: funnel/views/account.py:792 +#: funnel/views/account.py:810 msgid "You are about to delete your account permanently" msgstr "" @@ -4796,53 +5007,53 @@ msgstr "" msgid "Send OTP" msgstr "" -#: funnel/views/account_reset.py:142 funnel/views/account_reset.py:219 +#: funnel/views/account_reset.py:142 funnel/views/account_reset.py:221 msgid "" "This password reset link is invalid. If you still need to reset your " "password, you may request an OTP" msgstr "" -#: funnel/views/account_reset.py:175 +#: funnel/views/account_reset.py:177 msgid "Verify OTP" msgstr "" -#: funnel/views/account_reset.py:196 +#: funnel/views/account_reset.py:198 msgid "This page has timed out" msgstr "" -#: funnel/views/account_reset.py:197 +#: funnel/views/account_reset.py:199 msgid "Open the reset link again to reset your password" msgstr "" -#: funnel/views/account_reset.py:208 +#: funnel/views/account_reset.py:210 msgid "" "This password reset link has expired. If you still need to reset your " "password, you may request an OTP" msgstr "" -#: funnel/views/account_reset.py:235 funnel/views/api/oauth.py:473 +#: funnel/views/account_reset.py:237 funnel/views/api/oauth.py:473 msgid "Unknown user" msgstr "अज्ञात यूजर" -#: funnel/views/account_reset.py:236 +#: funnel/views/account_reset.py:238 msgid "There is no account matching this password reset request" msgstr "इस पासवर्ड रीसेट अनुरोध से मेल खाने योग्य कोई खाता नहीं है" -#: funnel/views/account_reset.py:244 +#: funnel/views/account_reset.py:246 msgid "" "This password reset link has been used. If you need to reset your " "password again, you may request an OTP" msgstr "" -#: funnel/views/account_reset.py:270 +#: funnel/views/account_reset.py:272 msgid "Password reset complete" msgstr "पासवर्ड रीसेट पूरा हुआ" -#: funnel/views/account_reset.py:271 +#: funnel/views/account_reset.py:273 msgid "Your password has been changed. You may now login with your new password" msgstr "आपका पासवर्ड बदल दिया गया है. अब आप अपने नए पासवर्ड से लॉगिन कर सकते हैं" -#: funnel/views/account_reset.py:276 +#: funnel/views/account_reset.py:278 msgid "" "Your password has been changed. As a precaution, you have been logged out" " of one other device. You may now login with your new password" @@ -4850,19 +5061,19 @@ msgstr "" "आपका पासवर्ड बदल दिया गया है. एहतियात के तौर पर, आपको एक अन्य डिवाइस से " "लॉग आउट किया गया है. अब आप अपने नए पासवर्ड से लॉगिन कर सकते हैं" -#: funnel/views/account_reset.py:292 +#: funnel/views/account_reset.py:294 msgid "Hello, {fullname}. You may now choose a new password" msgstr "नमस्ते, {fullname}. अब आप एक नया पासवर्ड चुन सकते हैं" -#: funnel/views/auth_client.py:93 +#: funnel/views/auth_client.py:94 msgid "Register a new client application" msgstr "एक नया क्लाइंट ऐप्लिकेशन रजिस्टर करें" -#: funnel/views/auth_client.py:95 +#: funnel/views/auth_client.py:96 msgid "Register application" msgstr "ऐप्लिकेशन रजिस्टर करें" -#: funnel/views/auth_client.py:146 +#: funnel/views/auth_client.py:147 msgid "" "This application’s owner has changed, so all previously assigned " "permissions have been revoked" @@ -4870,21 +5081,22 @@ msgstr "" "इस ऐप्लिकेशन का ओनर बदल गया है, इसलिए पहले से सौंपी गई सभी अनुमतियां रद्द" " कर दी गई हैं" -#: funnel/views/auth_client.py:160 +#: funnel/views/auth_client.py:161 msgid "Edit application" msgstr "ऐप्लिकेशन संपादित करें" -#: funnel/views/auth_client.py:173 funnel/views/auth_client.py:332 -#: funnel/views/auth_client.py:408 funnel/views/auth_client.py:483 -#: funnel/views/label.py:254 funnel/views/organization.py:115 -#: funnel/views/organization.py:204 funnel/views/project.py:415 -#: funnel/views/proposal.py:306 funnel/views/ticket_event.py:200 -#: funnel/views/ticket_event.py:288 funnel/views/ticket_event.py:349 -#: funnel/views/update.py:176 +#: funnel/views/auth_client.py:174 funnel/views/auth_client.py:333 +#: funnel/views/auth_client.py:409 funnel/views/auth_client.py:484 +#: funnel/views/label.py:255 funnel/views/organization.py:115 +#: funnel/views/organization.py:204 funnel/views/project.py:429 +#: funnel/views/proposal.py:318 funnel/views/ticket_event.py:199 +#: funnel/views/ticket_event.py:288 funnel/views/ticket_event.py:350 +#: funnel/views/update.py:176 funnel/views/venue.py:133 +#: funnel/views/venue.py:196 msgid "Confirm delete" msgstr "मिटाने की पुष्टि करें" -#: funnel/views/auth_client.py:174 +#: funnel/views/auth_client.py:175 msgid "" "Delete application ‘{title}’? This will also delete all associated " "content including access tokens issued on behalf of users. This operation" @@ -4894,7 +5106,7 @@ msgstr "" "जुड़ी सामग्री को भी हटा देगा. यह प्रक्रिया स्थाई है और इसे वापस नहीं लाया" " जा सकता है" -#: funnel/views/auth_client.py:179 +#: funnel/views/auth_client.py:180 msgid "" "You have deleted application ‘{title}’ and all its associated resources " "and permission assignments" @@ -4902,11 +5114,11 @@ msgstr "" "आपने ‘{title}’ ऐप्लिकेशन और उससे जुड़ी सभी संसाधनों और सौंपी गई अनुमति को" " मिटा दिया है" -#: funnel/views/auth_client.py:196 +#: funnel/views/auth_client.py:197 msgid "Disconnect {app}" msgstr "{app} डिस्कनेक्ट करें" -#: funnel/views/auth_client.py:197 +#: funnel/views/auth_client.py:198 msgid "" "Disconnect application {app}? This will not remove any of your data in " "this app, but will prevent it from accessing any further data from your " @@ -4916,80 +5128,80 @@ msgstr "" " को नहीं हटाएगा, बल्कि इसे आपके Hasgeek खाते से किसी और डेटा को एक्सेस " "करने से रोकेगा" -#: funnel/views/auth_client.py:203 +#: funnel/views/auth_client.py:204 msgid "You have disconnected {app} from your account" msgstr "आपने अपने खाते से {app} को डिस्कनेक्ट कर दिया है" -#: funnel/views/auth_client.py:215 +#: funnel/views/auth_client.py:216 msgid "Default" msgstr "तयशुदा" -#: funnel/views/auth_client.py:230 funnel/views/organization.py:147 +#: funnel/views/auth_client.py:231 funnel/views/organization.py:147 #: funnel/views/venue.py:158 msgid "Create" msgstr "बनाएं" -#: funnel/views/auth_client.py:277 +#: funnel/views/auth_client.py:278 msgid "Permissions have been assigned to user {pname}" msgstr "अनुमतियां यूजर {pname} को सौंप दी गई हैं" -#: funnel/views/auth_client.py:284 +#: funnel/views/auth_client.py:285 msgid "Permissions have been assigned to team ‘{pname}’" msgstr "अनुमतियां टीम ‘{pname}’ को सौंप दी गई हैं" -#: funnel/views/auth_client.py:292 funnel/views/auth_client.py:302 +#: funnel/views/auth_client.py:293 funnel/views/auth_client.py:303 msgid "Assign permissions" msgstr "अनुमतियां सौंपें" -#: funnel/views/auth_client.py:294 +#: funnel/views/auth_client.py:295 msgid "" "Add and edit teams from your organization’s teams " "page" msgstr "आपके संगठन के टीम पेज से टीम जोड़ें और संपादित करें" -#: funnel/views/auth_client.py:333 +#: funnel/views/auth_client.py:334 msgid "Delete access key ‘{title}’? " msgstr "ऐक्सेस की ‘{title}’ मिटाएं?" -#: funnel/views/auth_client.py:334 +#: funnel/views/auth_client.py:335 msgid "You have deleted access key ‘{title}’" msgstr "आपने ऐक्सेस की ‘{title}’ मिटा दी है" -#: funnel/views/auth_client.py:380 +#: funnel/views/auth_client.py:381 msgid "Permissions have been updated for user {pname}" msgstr "{pname} यूजर के लिए अनुमतियां अपडेट कर दी गई हैं" -#: funnel/views/auth_client.py:387 +#: funnel/views/auth_client.py:388 msgid "All permissions have been revoked for user {pname}" msgstr "{pname} यूजर के लिए सभी अनुमतियां रद्द कर दी गई हैं" -#: funnel/views/auth_client.py:395 funnel/views/auth_client.py:470 +#: funnel/views/auth_client.py:396 funnel/views/auth_client.py:471 msgid "Edit permissions" msgstr "अनुमतियां संपादित करें" -#: funnel/views/auth_client.py:409 +#: funnel/views/auth_client.py:410 msgid "Remove all permissions assigned to user {pname} for app ‘{title}’?" msgstr "‘{title}’ ऐप के लिए {pname} यूजर को सौंपी गई सभी अनुमतियां हटाएं?" -#: funnel/views/auth_client.py:412 +#: funnel/views/auth_client.py:413 msgid "You have revoked permisions for user {pname}" msgstr "आपने {pname} यूजर के लिए अनुमतियां रद्द कर दी है" -#: funnel/views/auth_client.py:455 +#: funnel/views/auth_client.py:456 msgid "Permissions have been updated for team {title}" msgstr "{title} टीम के लिए अनुमतियां अपडेट कर दी गई हैं" -#: funnel/views/auth_client.py:462 +#: funnel/views/auth_client.py:463 msgid "All permissions have been revoked for team {title}" msgstr "{title} टीम के लिए सभी अनुमतियां रद्द कर दी गई हैं" -#: funnel/views/auth_client.py:484 +#: funnel/views/auth_client.py:485 msgid "Remove all permissions assigned to team ‘{pname}’ for app ‘{title}’?" msgstr "" "‘{title}’ ऐप के लिए ‘{pname}’ टीम को सौंपी गई सभी अनुमतियां हटाना चाहते " "हैं?" -#: funnel/views/auth_client.py:487 +#: funnel/views/auth_client.py:488 msgid "You have revoked permisions for team {title}" msgstr "आपने {title} टीम के लिए अनुमतियां रद्द कर दी है" @@ -5017,7 +5229,7 @@ msgstr "" msgid "Request expired. Reload and try again" msgstr "" -#: funnel/views/comment.py:265 funnel/views/project.py:698 +#: funnel/views/comment.py:265 funnel/views/project.py:718 msgid "This page timed out. Reload and try again" msgstr "" "इस पेज को लोड होने का समय समाप्त हो चुका है. पेज को रिलोड करने के बाद फिर" @@ -5059,30 +5271,18 @@ msgstr "यह प्रोजेक्ट समाप्त हो गई ह msgid "Unauthorized contact exchange" msgstr "अनधिकृत संपर्क विनिमय" -#: funnel/views/email.py:16 +#: funnel/views/email.py:15 msgid "Verify your email address" msgstr "अपना ईमेल पता वेरिफाई करें" -#: funnel/views/email.py:25 +#: funnel/views/email.py:24 msgid "Verify email address" msgstr "ईमेल पता वेरिफाई करें" -#: funnel/views/email.py:37 funnel/views/otp.py:456 +#: funnel/views/email.py:36 funnel/views/otp.py:475 msgid "Reset your password - OTP {otp}" msgstr "" -#: funnel/views/email.py:61 -msgid "You have been added to {project} as a crew member" -msgstr "आपको दल के सदस्य के रूप में {project} में जोड़ा गया है" - -#: funnel/views/email.py:79 -msgid "You have been invited to {project} as a crew member" -msgstr "आपको दल के सदस्य के रूप में {project} में आमंत्रित किया गया है" - -#: funnel/views/email.py:97 -msgid "You have been removed from {project} as a crew member" -msgstr "आपको दल के सदस्य के रूप में {project} से हटा दिया गया है" - #: funnel/views/index.py:29 msgid "Terms of service" msgstr "सेवा की शर्तें" @@ -5109,23 +5309,23 @@ msgstr "आचार संहिता" #: funnel/views/label.py:40 funnel/views/profile.py:281 #: funnel/views/profile.py:313 funnel/views/profile.py:360 -#: funnel/views/profile.py:398 funnel/views/project.py:380 -#: funnel/views/project.py:451 funnel/views/project.py:492 -#: funnel/views/project.py:517 funnel/views/proposal.py:234 -#: funnel/views/ticket_event.py:189 funnel/views/ticket_event.py:275 -#: funnel/views/ticket_event.py:336 funnel/views/ticket_participant.py:208 +#: funnel/views/profile.py:398 funnel/views/project.py:394 +#: funnel/views/project.py:465 funnel/views/project.py:506 +#: funnel/views/project.py:537 funnel/views/proposal.py:235 +#: funnel/views/ticket_event.py:188 funnel/views/ticket_event.py:275 +#: funnel/views/ticket_event.py:337 funnel/views/ticket_participant.py:208 msgid "Your changes have been saved" msgstr "आपके परिवर्तनों को सेव किया गया है" -#: funnel/views/label.py:80 funnel/views/label.py:176 +#: funnel/views/label.py:81 funnel/views/label.py:176 msgid "Error with a label option: {}" msgstr "लेबल विकल्प में त्रुटि: {}" -#: funnel/views/label.py:83 funnel/views/label.py:93 +#: funnel/views/label.py:84 funnel/views/label.py:94 msgid "Add label" msgstr "" -#: funnel/views/label.py:145 +#: funnel/views/label.py:144 msgid "Only main labels can be edited" msgstr "केवल मुख्य लेबल संपादित किए जा सकते हैं" @@ -5153,11 +5353,11 @@ msgstr "" msgid "Labels that have been assigned to submissions cannot be deleted" msgstr "" -#: funnel/views/label.py:250 +#: funnel/views/label.py:251 msgid "The label has been deleted" msgstr "लेबल मिटा दिया गया है" -#: funnel/views/label.py:255 +#: funnel/views/label.py:256 msgid "Delete this label? This operation is permanent and cannot be undone" msgstr "" @@ -5165,7 +5365,7 @@ msgstr "" msgid "Are you trying to logout? Try again to confirm" msgstr "" -#: funnel/views/login.py:182 +#: funnel/views/login.py:187 msgid "" "You have a weak password. To ensure the safety of your account, please " "choose a stronger password" @@ -5173,7 +5373,7 @@ msgstr "" "आपका पासवर्ड कमज़ोर है. अपने खाते की सुरक्षा सुनिश्चित करने के लिए, कृपया" " एक मज़बूत पासवर्ड चुनें" -#: funnel/views/login.py:200 +#: funnel/views/login.py:205 msgid "" "Your password is a year old. To ensure the safety of your account, please" " choose a new password" @@ -5181,68 +5381,68 @@ msgstr "" "आपका पासवर्ड एक साल पुराना है. अपने खाते की सुरक्षा सुनिश्चित करने के " "लिए, कृपया एक नया पासवर्ड चुनें" -#: funnel/views/login.py:215 funnel/views/login.py:291 -#: funnel/views/login.py:739 +#: funnel/views/login.py:220 funnel/views/login.py:299 +#: funnel/views/login.py:751 msgid "You are now logged in" msgstr "अब आप लॉग इन हो गए हैं" -#: funnel/views/login.py:222 +#: funnel/views/login.py:227 msgid "" "Your account does not have a password. Please enter your phone number or " "email address to request an OTP and set a new password" msgstr "" -#: funnel/views/login.py:232 +#: funnel/views/login.py:237 msgid "" "Your account has a weak password. Please enter your phone number or email" " address to request an OTP and set a new password" msgstr "" -#: funnel/views/login.py:281 +#: funnel/views/login.py:289 msgid "You are now one of us. Welcome aboard!" msgstr "आप अब हम में से एक हैं. दल में स्वागत है!" -#: funnel/views/login.py:301 +#: funnel/views/login.py:311 msgid "The OTP has expired. Try again?" msgstr "" -#: funnel/views/login.py:364 +#: funnel/views/login.py:375 msgid "To logout, use the logout button" msgstr "लॉग आउट करने के लिए, लॉग आउट बटन का उपयोग करें" -#: funnel/views/login.py:384 funnel/views/login.py:769 +#: funnel/views/login.py:395 funnel/views/login.py:781 msgid "You are now logged out" msgstr "अब आप लॉग आउट हो चुके हैं" -#: funnel/views/login.py:428 +#: funnel/views/login.py:439 msgid "{service} login failed: {error}" msgstr "{service} लॉगिन विफल: {error}" -#: funnel/views/login.py:570 +#: funnel/views/login.py:581 msgid "You have logged in via {service}" msgstr "आपने {service} की मदद से लॉगिन किया है" -#: funnel/views/login.py:614 +#: funnel/views/login.py:625 msgid "Your accounts have been merged" msgstr "आपके खाते जोड़ दिए गए हैं" -#: funnel/views/login.py:619 +#: funnel/views/login.py:630 msgid "Account merger failed" msgstr "खाता जोड़ना विफल रहा" -#: funnel/views/login.py:674 +#: funnel/views/login.py:686 msgid "Cookies required" msgstr "कुकीज़ की ज़रूरत" -#: funnel/views/login.py:675 +#: funnel/views/login.py:687 msgid "Please enable cookies in your browser" msgstr "कृपया अपने ब्राउज़र में कुकीज़ को सक्षम करें" -#: funnel/views/login.py:724 +#: funnel/views/login.py:736 msgid "Your attempt to login failed. Try again?" msgstr "" -#: funnel/views/login.py:730 +#: funnel/views/login.py:742 msgid "Are you trying to login? Try again to confirm" msgstr "" @@ -5268,20 +5468,20 @@ msgstr "" msgid "Your account is not active" msgstr "" -#: funnel/views/login_session.py:460 funnel/views/login_session.py:487 -#: funnel/views/login_session.py:560 +#: funnel/views/login_session.py:462 funnel/views/login_session.py:489 +#: funnel/views/login_session.py:562 msgid "You need to be logged in for that page" msgstr "आपको उस पेज के लिए लॉगिन होना चाहिए" -#: funnel/views/login_session.py:466 +#: funnel/views/login_session.py:468 msgid "Confirm your phone number to continue" msgstr "" -#: funnel/views/login_session.py:579 +#: funnel/views/login_session.py:581 msgid "This request requires re-authentication" msgstr "" -#: funnel/views/login_session.py:635 +#: funnel/views/login_session.py:637 msgid "" "This operation requires you to confirm your password. However, your " "account does not have a password, so you must set one first" @@ -5289,15 +5489,15 @@ msgstr "" "इस प्रक्रिया के लिए आपको अपने पासवर्ड की पुष्टि करनी होगी. हालांकि, आपके " "खाते में पासवर्ड सेट नहीं है, इसलिए आपको पहले एक पासवर्ड सेट करना होगा" -#: funnel/views/login_session.py:677 +#: funnel/views/login_session.py:681 msgid "Confirm this operation with an OTP" msgstr "" -#: funnel/views/login_session.py:680 +#: funnel/views/login_session.py:684 msgid "Confirm with your password to proceed" msgstr "आगे बढ़ने के लिए अपने पासवर्ड से पुष्टि करें" -#: funnel/views/membership.py:80 funnel/views/membership.py:337 +#: funnel/views/membership.py:82 funnel/views/membership.py:329 msgid "" "This user does not have any verified contact information. If you are able" " to contact them, please ask them to verify their email address or phone " @@ -5307,76 +5507,80 @@ msgstr "" "करने में सक्षम हैं, तो कृपया उन्हें अपना ईमेल पता या फोन नंबर वेरिफाई " "करने के लिए कहें" -#: funnel/views/membership.py:106 +#: funnel/views/membership.py:108 msgid "This user is already an admin" msgstr "यह यूजर पहले से ही एक एडमिन है" -#: funnel/views/membership.py:126 +#: funnel/views/membership.py:128 msgid "The user has been added as an admin" msgstr "यूजर को एक एडमिन के रूप में जोड़ दिया गया है" -#: funnel/views/membership.py:137 +#: funnel/views/membership.py:139 msgid "The new admin could not be added" msgstr "नया एडमिन जोड़ा नहीं जा सका" -#: funnel/views/membership.py:187 +#: funnel/views/membership.py:189 msgid "You can’t edit your own role" msgstr "आप अपनी भूमिका संपादित नहीं कर सकते" -#: funnel/views/membership.py:199 +#: funnel/views/membership.py:200 msgid "This member’s record was edited elsewhere. Reload the page" msgstr "इस सदस्य का रिकॉर्ड कहीं और संपादित किया गया था. पेज को रिलोड करें" -#: funnel/views/membership.py:218 funnel/views/membership.py:515 +#: funnel/views/membership.py:217 funnel/views/membership.py:513 msgid "The member’s roles have been updated" msgstr "सदस्य की भूमिकाओं को अपडेट किया गया है" -#: funnel/views/membership.py:220 +#: funnel/views/membership.py:219 msgid "No changes were detected" msgstr "कोई परिवर्तन नहीं पाया गया" -#: funnel/views/membership.py:232 funnel/views/membership.py:394 -#: funnel/views/membership.py:526 +#: funnel/views/membership.py:230 funnel/views/membership.py:375 +#: funnel/views/membership.py:523 msgid "Please pick one or more roles" msgstr "कृपया एक या एक से अधिक भूमिकाएं चुनें" -#: funnel/views/membership.py:259 +#: funnel/views/membership.py:255 msgid "You can’t revoke your own membership" msgstr "आप अपनी स्वयं की सदस्यता रद्द नहीं कर सकते" -#: funnel/views/membership.py:273 funnel/views/membership.py:566 +#: funnel/views/membership.py:269 funnel/views/membership.py:559 msgid "The member has been removed" msgstr "सदस्य को हटा दिया गया है" -#: funnel/views/membership.py:293 +#: funnel/views/membership.py:286 msgid "Remove {member} as an admin from {account}?" msgstr "{account} से एडमिन के रूप में {member} को हटाना चाहते हैं?" -#: funnel/views/membership.py:357 +#: funnel/views/membership.py:345 msgid "This person is already a member" msgstr "यह व्यक्ति पहले से ही एक सदस्य है" -#: funnel/views/membership.py:383 +#: funnel/views/membership.py:365 msgid "The user has been added as a member" msgstr "यूज़र को सदस्य के रूप में जोड़ दिया गया है" -#: funnel/views/membership.py:500 +#: funnel/views/membership.py:449 +msgid "This is not a valid response" +msgstr "" + +#: funnel/views/membership.py:495 msgid "The member’s record was edited elsewhere. Reload the page" msgstr "सदस्य का रिकॉर्ड कहीं और संपादित किया गया था. पेज को रिलोड करें" -#: funnel/views/membership.py:579 +#: funnel/views/membership.py:572 msgid "Remove {member} as a crew member from this project?" msgstr "इस प्रोजेक्ट से एडमिन के रूप में {member} को हटाना चाहते हैं?" -#: funnel/views/mixins.py:242 +#: funnel/views/mixins.py:238 msgid "There is no draft for the given object" msgstr "दिए गए ऑब्जेक्ट का कोई ड्राफ्ट नहीं है" -#: funnel/views/mixins.py:267 +#: funnel/views/mixins.py:263 msgid "Form must contain a revision ID" msgstr "फॉर्म में एक संशोधन ID होना चाहिए" -#: funnel/views/mixins.py:290 +#: funnel/views/mixins.py:286 msgid "" "Invalid revision ID or the existing changes have been submitted already. " "Please reload" @@ -5384,7 +5588,7 @@ msgstr "" "अमान्य संशोधन ID या मौजूदा परिवर्तन पहले ही जमा किए जा चुके हैं. कृपया " "रीलोड करें" -#: funnel/views/mixins.py:308 +#: funnel/views/mixins.py:304 msgid "" "There have been changes to this draft since you last edited it. Please " "reload" @@ -5392,15 +5596,15 @@ msgstr "" "आपके पिछले संपादन के बाद से इस ड्राफ्ट में बदलाव किए गए हैं. कृपया रीलोड " "करें" -#: funnel/views/mixins.py:348 +#: funnel/views/mixins.py:344 msgid "Invalid CSRF token" msgstr "अमान्य CSRF टोकन" -#: funnel/views/notification.py:66 +#: funnel/views/notification.py:142 msgid "You are receiving this because you have an account at hasgeek.com" msgstr "आप इसे प्राप्त कर रहे हैं क्योंकि आपका hasgeek.com पर एक खाता है" -#: funnel/views/notification_preferences.py:44 +#: funnel/views/notification_preferences.py:45 msgid "" "That unsubscribe link has expired. However, you can manage your " "preferences from your account page" @@ -5408,7 +5612,7 @@ msgstr "" "वो सदस्यता समाप्त वाली लिंक एक्स्पायर हो चुकी है. हालांकि, आप अपनी " "प्राथमिकताएं अपने खाते से प्रबंधित कर सकते हैं" -#: funnel/views/notification_preferences.py:49 +#: funnel/views/notification_preferences.py:50 msgid "" "That unsubscribe link is invalid. However, you can manage your " "preferences from your account page" @@ -5416,12 +5620,12 @@ msgstr "" "वो सदस्यता समाप्त वाली लिंक अमान्य है. हालांकि, आप अपनी प्राथमिकताएं अपने" " खाते से प्रबंधित कर सकते हैं" -#: funnel/views/notification_preferences.py:205 +#: funnel/views/notification_preferences.py:206 #: funnel/views/notification_preferences.py:397 msgid "This unsubscribe link is for a non-existent user" msgstr "यह सदस्यता समाप्त लिंक एक गैर-मौजूद यूजर के लिए है" -#: funnel/views/notification_preferences.py:223 +#: funnel/views/notification_preferences.py:224 msgid "You have been unsubscribed from this notification type" msgstr "आपको इस प्रकार की नोटिफिकेशन की सदस्यता से हटा दिया गया है" @@ -5442,19 +5646,19 @@ msgstr "" msgid "Unknown user account" msgstr "अज्ञात यूजर खाता" -#: funnel/views/notification_preferences.py:430 +#: funnel/views/notification_preferences.py:441 msgid "Preferences saved" msgstr "प्राथमिकताएं सहेजी गईं" -#: funnel/views/notification_preferences.py:431 +#: funnel/views/notification_preferences.py:442 msgid "Your notification preferences have been updated" msgstr "आपकी नोटिफिकेशन प्राथमिकताएं अपडेट कर दी गई हैं" -#: funnel/views/notification_preferences.py:435 +#: funnel/views/notification_preferences.py:446 msgid "Notification preferences" msgstr "नोटिफिकेशन प्राथमिकताएं" -#: funnel/views/notification_preferences.py:437 +#: funnel/views/notification_preferences.py:448 msgid "Save preferences" msgstr "प्राथमिकताएं सेव करें" @@ -5509,45 +5713,45 @@ msgstr "{title} टीम मिटाएं?" msgid "You have deleted team ‘{team}’ from organization ‘{org}’" msgstr "आपने ‘{org}’ संगठन से ‘{team}’ टीम को मिटा दिया है" -#: funnel/views/otp.py:253 +#: funnel/views/otp.py:252 msgid "Unable to send an OTP to your phone number {number} right now" msgstr "" -#: funnel/views/otp.py:264 funnel/views/otp.py:365 +#: funnel/views/otp.py:263 funnel/views/otp.py:382 msgid "An OTP has been sent to your phone number {number}" msgstr "" -#: funnel/views/otp.py:327 +#: funnel/views/otp.py:344 msgid "Your phone number {number} is not supported for SMS. Use password to login" msgstr "" -#: funnel/views/otp.py:335 +#: funnel/views/otp.py:352 msgid "" "Your phone number {number} is not supported for SMS. Use an email address" " to register" msgstr "" -#: funnel/views/otp.py:345 +#: funnel/views/otp.py:362 msgid "" "Unable to send an OTP to your phone number {number} right now. Use " "password to login, or try again later" msgstr "" -#: funnel/views/otp.py:353 +#: funnel/views/otp.py:370 msgid "" "Unable to send an OTP to your phone number {number} right now. Use an " "email address to register, or try again later" msgstr "" -#: funnel/views/otp.py:383 +#: funnel/views/otp.py:400 msgid "Login OTP {otp}" msgstr "" -#: funnel/views/otp.py:391 funnel/views/otp.py:431 funnel/views/otp.py:474 +#: funnel/views/otp.py:408 funnel/views/otp.py:450 funnel/views/otp.py:493 msgid "An OTP has been sent to your email address {email}" msgstr "" -#: funnel/views/otp.py:423 +#: funnel/views/otp.py:442 msgid "Confirmation OTP {otp}" msgstr "" @@ -5564,11 +5768,11 @@ msgid "Were you trying to remove the logo? Try again to confirm" msgstr "" #: funnel/views/profile.py:362 funnel/views/profile.py:366 -#: funnel/views/project.py:453 funnel/views/project.py:457 +#: funnel/views/project.py:467 funnel/views/project.py:471 msgid "Save banner" msgstr "बैनर सेव करें" -#: funnel/views/profile.py:384 funnel/views/project.py:476 +#: funnel/views/profile.py:384 funnel/views/project.py:490 msgid "Were you trying to remove the banner? Try again to confirm" msgstr "" @@ -5578,225 +5782,225 @@ msgstr "" "आपके परिवर्तनों को सेव करने में एक समस्या उत्पन्न हुई थी. कृपया दोबारा " "प्रयास करें" -#: funnel/views/project.py:71 +#: funnel/views/project.py:72 msgid "Be the first to register!" msgstr "रजिस्टर करने वाले पहले व्यक्ति बनें!" -#: funnel/views/project.py:71 +#: funnel/views/project.py:72 msgid "Be the first follower!" msgstr "" -#: funnel/views/project.py:73 +#: funnel/views/project.py:74 msgid "One registration so far" msgstr "अब तक एक रजिस्ट्रेशन" -#: funnel/views/project.py:74 +#: funnel/views/project.py:75 msgid "You have registered" msgstr "आपने रजिस्टर कर लिया है" -#: funnel/views/project.py:75 +#: funnel/views/project.py:76 msgid "One follower so far" msgstr "" -#: funnel/views/project.py:76 +#: funnel/views/project.py:77 msgid "You are following this" msgstr "" -#: funnel/views/project.py:79 +#: funnel/views/project.py:80 msgid "Two registrations so far" msgstr "अब तक दो रजिस्ट्रेशन" -#: funnel/views/project.py:80 -msgid "You and one other have registered" -msgstr "आपने और एक अन्य व्यक्ति ने रजिस्टर कर लिया है" - #: funnel/views/project.py:81 -msgid "Two followers so far" +msgid "You & one other have registered" msgstr "" #: funnel/views/project.py:82 -msgid "You and one other are following" +msgid "Two followers so far" msgstr "" -#: funnel/views/project.py:85 -msgid "Three registrations so far" -msgstr "अब तक तीन रजिस्ट्रेशन" +#: funnel/views/project.py:83 +msgid "You & one other are following" +msgstr "" #: funnel/views/project.py:86 -msgid "You and two others have registered" -msgstr "आपने और दो अन्य व्यक्ति ने रजिस्टर कर लिया है" +msgid "Three registrations so far" +msgstr "अब तक तीन रजिस्ट्रेशन" #: funnel/views/project.py:87 -msgid "Three followers so far" +msgid "You & two others have registered" msgstr "" #: funnel/views/project.py:88 -msgid "You and two others are following" +msgid "Three followers so far" msgstr "" -#: funnel/views/project.py:91 -msgid "Four registrations so far" -msgstr "अब तक चार रजिस्ट्रेशन" +#: funnel/views/project.py:89 +msgid "You & two others are following" +msgstr "" #: funnel/views/project.py:92 -msgid "You and three others have registered" -msgstr "आपने और तीन अन्य व्यक्ति ने रजिस्टर कर लिया है" +msgid "Four registrations so far" +msgstr "अब तक चार रजिस्ट्रेशन" #: funnel/views/project.py:93 -msgid "Four followers so far" +msgid "You & three others have registered" msgstr "" #: funnel/views/project.py:94 -msgid "You and three others are following" +msgid "Four followers so far" msgstr "" -#: funnel/views/project.py:97 -msgid "Five registrations so far" -msgstr "अब तक पांच रजिस्ट्रेशन" +#: funnel/views/project.py:95 +msgid "You & three others are following" +msgstr "" #: funnel/views/project.py:98 -msgid "You and four others have registered" -msgstr "आपने और चार अन्य व्यक्ति ने रजिस्टर कर लिया है" +msgid "Five registrations so far" +msgstr "अब तक पांच रजिस्ट्रेशन" #: funnel/views/project.py:99 -msgid "Five followers so far" +msgid "You & four others have registered" msgstr "" #: funnel/views/project.py:100 -msgid "You and four others are following" +msgid "Five followers so far" msgstr "" -#: funnel/views/project.py:103 -msgid "Six registrations so far" -msgstr "अब तक छे रजिस्ट्रेशन" +#: funnel/views/project.py:101 +msgid "You & four others are following" +msgstr "" #: funnel/views/project.py:104 -msgid "You and five others have registered" -msgstr "आपने और पांच अन्य व्यक्ति ने रजिस्टर कर लिया है" +msgid "Six registrations so far" +msgstr "अब तक छे रजिस्ट्रेशन" #: funnel/views/project.py:105 -msgid "Six followers so far" +msgid "You & five others have registered" msgstr "" #: funnel/views/project.py:106 -msgid "You and five others are following" +msgid "Six followers so far" msgstr "" -#: funnel/views/project.py:109 -msgid "Seven registrations so far" -msgstr "अब तक सात रजिस्ट्रेशन" +#: funnel/views/project.py:107 +msgid "You & five others are following" +msgstr "" #: funnel/views/project.py:110 -msgid "You and six others have registered" -msgstr "आपने और छे अन्य व्यक्ति ने रजिस्टर कर लिया है" +msgid "Seven registrations so far" +msgstr "अब तक सात रजिस्ट्रेशन" #: funnel/views/project.py:111 -msgid "Seven followers so far" +msgid "You & six others have registered" msgstr "" #: funnel/views/project.py:112 -msgid "You and six others are following" +msgid "Seven followers so far" msgstr "" -#: funnel/views/project.py:115 -msgid "Eight registrations so far" +#: funnel/views/project.py:113 +msgid "You & six others are following" msgstr "" #: funnel/views/project.py:116 -msgid "You and seven others have registered" -msgstr "आपने और सात अन्य व्यक्ति ने रजिस्टर कर लिया है" +msgid "Eight registrations so far" +msgstr "" #: funnel/views/project.py:117 -msgid "Eight followers so far" +msgid "You & seven others have registered" msgstr "" #: funnel/views/project.py:118 -msgid "You and seven others are following" +msgid "Eight followers so far" msgstr "" -#: funnel/views/project.py:121 -msgid "Nine registrations so far" -msgstr "अब तक नौ रजिस्ट्रेशन" +#: funnel/views/project.py:119 +msgid "You & seven others are following" +msgstr "" #: funnel/views/project.py:122 -msgid "You and eight others have registered" -msgstr "आपने और आठ अन्य व्यक्ति ने रजिस्टर कर लिया है" +msgid "Nine registrations so far" +msgstr "अब तक नौ रजिस्ट्रेशन" #: funnel/views/project.py:123 -msgid "Nine followers so far" +msgid "You & eight others have registered" msgstr "" #: funnel/views/project.py:124 -msgid "You and eight others are following" +msgid "Nine followers so far" msgstr "" -#: funnel/views/project.py:127 -msgid "Ten registrations so far" -msgstr "अब तक दस रजिस्ट्रेशन" +#: funnel/views/project.py:125 +msgid "You & eight others are following" +msgstr "" #: funnel/views/project.py:128 -msgid "You and nine others have registered" -msgstr "आपने और नौ अन्य व्यक्ति ने रजिस्टर कर लिया है" +msgid "Ten registrations so far" +msgstr "अब तक दस रजिस्ट्रेशन" #: funnel/views/project.py:129 -msgid "Ten followers so far" +msgid "You & nine others have registered" msgstr "" #: funnel/views/project.py:130 -msgid "You and nine others are following" +msgid "Ten followers so far" msgstr "" -#: funnel/views/project.py:134 -msgid "{num} registrations so far" -msgstr "अब तक {num} रजिस्ट्रेशन" +#: funnel/views/project.py:131 +msgid "You & nine others are following" +msgstr "" #: funnel/views/project.py:135 -msgid "You and {num} others have registered" -msgstr "आपने और {num} अन्य व्यक्ति ने रजिस्टर कर लिया है" +msgid "{num} registrations so far" +msgstr "अब तक {num} रजिस्ट्रेशन" #: funnel/views/project.py:136 -msgid "{num} followers so far" +msgid "You & {num} others have registered" msgstr "" #: funnel/views/project.py:137 -msgid "You and {num} others are following" +msgid "{num} followers so far" msgstr "" -#: funnel/views/project.py:228 +#: funnel/views/project.py:138 +msgid "You & {num} others are following" +msgstr "" + +#: funnel/views/project.py:240 msgid "Follow" msgstr "" -#: funnel/views/project.py:254 +#: funnel/views/project.py:268 msgid "Your new project has been created" msgstr "आपका नया प्रोजेक्ट बना दिया गया है" -#: funnel/views/project.py:263 +#: funnel/views/project.py:277 msgid "Create project" msgstr "प्रोजेक्ट बनाएं" -#: funnel/views/project.py:329 +#: funnel/views/project.py:343 msgid "Customize the URL" msgstr "URL कस्टमाइज़ करें" -#: funnel/views/project.py:342 +#: funnel/views/project.py:356 msgid "Add or edit livestream URLs" msgstr "लाइवस्ट्रीम URLs जोड़ें या संपादित करें" -#: funnel/views/project.py:369 funnel/views/project.py:393 +#: funnel/views/project.py:383 funnel/views/project.py:407 msgid "Edit project" msgstr "प्रोजेक्ट संपादित करें" -#: funnel/views/project.py:406 +#: funnel/views/project.py:420 msgid "This project has submissions" msgstr "" -#: funnel/views/project.py:407 +#: funnel/views/project.py:421 msgid "" "Submissions must be deleted or transferred before the project can be " "deleted" msgstr "" -#: funnel/views/project.py:416 +#: funnel/views/project.py:430 msgid "" "Delete project ‘{title}’? This will delete everything in the project. " "This operation is permanent and cannot be undone" @@ -5804,79 +6008,83 @@ msgstr "" "‘{title}’ प्रोजेक्ट मिटाएं? यह प्रोजेक्ट में सब कुछ मिटा देगा. ऐसा करना " "इसे हमेशा के लिए मिटा देगा और इसे वापस पहले जैसा नहीं किया जा सकता" -#: funnel/views/project.py:420 +#: funnel/views/project.py:434 msgid "You have deleted project ‘{title}’ and all its associated content" msgstr "आपने ‘{title}’ प्रोजेक्ट और उससे जुड़ी सारी सामग्री हटा दी है" -#: funnel/views/project.py:522 +#: funnel/views/project.py:542 msgid "Edit ticket client details" msgstr "टिकट क्लाइंट विवरण संपादित करें" -#: funnel/views/project.py:542 +#: funnel/views/project.py:562 msgid "Invalid transition for this project" msgstr "इस प्रोजेक्ट के लिए अमान्य परिवर्तन" -#: funnel/views/project.py:558 +#: funnel/views/project.py:578 msgid "This project can now receive submissions" msgstr "" -#: funnel/views/project.py:562 +#: funnel/views/project.py:582 msgid "This project will no longer accept submissions" msgstr "" -#: funnel/views/project.py:567 +#: funnel/views/project.py:587 msgid "Invalid form submission" msgstr "" -#: funnel/views/project.py:588 +#: funnel/views/project.py:608 msgid "Were you trying to register? Try again to confirm" msgstr "" -#: funnel/views/project.py:610 +#: funnel/views/project.py:630 msgid "Were you trying to cancel your registration? Try again to confirm" msgstr "" -#: funnel/views/project.py:720 funnel/views/ticket_event.py:150 +#: funnel/views/project.py:740 funnel/views/ticket_event.py:149 msgid "Importing tickets from vendors… Reload the page in about 30 seconds…" msgstr "विक्रेताओं से टिकट आयात हो रहा है… पेज को लगभग 30 सेकंड में रिलोड करें…" -#: funnel/views/project.py:778 +#: funnel/views/project.py:798 msgid "This project has been featured" msgstr "" -#: funnel/views/project.py:781 +#: funnel/views/project.py:801 msgid "This project is no longer featured" msgstr "" -#: funnel/views/project_sponsor.py:54 +#: funnel/views/project_sponsor.py:56 msgid "{sponsor} is already a sponsor" msgstr "" -#: funnel/views/project_sponsor.py:69 +#: funnel/views/project_sponsor.py:71 msgid "Sponsor has been added" msgstr "" -#: funnel/views/project_sponsor.py:74 +#: funnel/views/project_sponsor.py:76 msgid "Sponsor could not be added" msgstr "" -#: funnel/views/project_sponsor.py:127 +#: funnel/views/project_sponsor.py:159 msgid "Sponsor has been edited" msgstr "" -#: funnel/views/project_sponsor.py:134 +#: funnel/views/project_sponsor.py:166 msgid "Sponsor could not be edited" msgstr "" -#: funnel/views/project_sponsor.py:156 +#: funnel/views/project_sponsor.py:188 msgid "Sponsor has been removed" msgstr "" -#: funnel/views/project_sponsor.py:162 +#: funnel/views/project_sponsor.py:194 msgid "Sponsor could not be removed" msgstr "" -#: funnel/views/project_sponsor.py:173 +#: funnel/views/project_sponsor.py:204 +msgid "Remove sponsor?" +msgstr "" + +#: funnel/views/project_sponsor.py:205 msgid "Remove ‘{sponsor}’ as a sponsor?" msgstr "" @@ -5898,11 +6106,15 @@ msgstr "ये प्रोजेक्ट इस वक्त कोई सब msgid "New submission" msgstr "" -#: funnel/views/proposal.py:260 +#: funnel/views/proposal.py:262 msgid "{user} has been added as an collaborator" msgstr "" -#: funnel/views/proposal.py:307 +#: funnel/views/proposal.py:276 +msgid "Pick a user to be added" +msgstr "" + +#: funnel/views/proposal.py:319 msgid "" "Delete your submission ‘{title}’? This will remove all comments as well. " "This operation is permanent and cannot be undone" @@ -5910,149 +6122,149 @@ msgstr "" "अपना ‘{title}’ प्रस्ताव मिटाएं? यह सभी कमेंटों को भी हटा देगा. ऐसा करना " "इसे हमेशा के लिए मिटा देगा और इसे वापस पहले जैसा नहीं किया जा सकता" -#: funnel/views/proposal.py:311 +#: funnel/views/proposal.py:323 msgid "Your submission has been deleted" msgstr "" -#: funnel/views/proposal.py:334 +#: funnel/views/proposal.py:346 msgid "Invalid transition for this submission" msgstr "" -#: funnel/views/proposal.py:349 +#: funnel/views/proposal.py:361 msgid "This submission has been moved to {project}" msgstr "" -#: funnel/views/proposal.py:356 +#: funnel/views/proposal.py:368 msgid "Please choose the project you want to move this submission to" msgstr "" -#: funnel/views/proposal.py:372 +#: funnel/views/proposal.py:384 msgid "This submission has been featured" msgstr "" -#: funnel/views/proposal.py:376 +#: funnel/views/proposal.py:388 msgid "This submission is no longer featured" msgstr "" -#: funnel/views/proposal.py:399 +#: funnel/views/proposal.py:411 msgid "Labels have been saved for this submission" msgstr "" -#: funnel/views/proposal.py:401 +#: funnel/views/proposal.py:413 msgid "Labels could not be saved for this submission" msgstr "" -#: funnel/views/proposal.py:405 +#: funnel/views/proposal.py:417 msgid "Edit labels for '{}'" msgstr "'{}' के लिए लेबल संपादित करें" -#: funnel/views/proposal.py:467 +#: funnel/views/proposal.py:479 msgid "{user}’s role has been updated" msgstr "" -#: funnel/views/proposal.py:496 +#: funnel/views/proposal.py:509 msgid "The sole collaborator on a submission cannot be removed" msgstr "" -#: funnel/views/proposal.py:504 +#: funnel/views/proposal.py:517 msgid "{user} is no longer a collaborator" msgstr "" -#: funnel/views/schedule.py:217 +#: funnel/views/schedule.py:222 msgid "{session} in {venue} in 5 minutes" msgstr "5 मिनट में {venue} में {session}" -#: funnel/views/schedule.py:221 +#: funnel/views/schedule.py:226 msgid "{session} in 5 minutes" msgstr "5 मिनट में {session}" -#: funnel/views/search.py:314 +#: funnel/views/search.py:320 msgid "Accounts" msgstr "अकाउंट" -#: funnel/views/session.py:34 +#: funnel/views/session.py:27 msgid "Select Room" msgstr "रूम का चयन करें" -#: funnel/views/session.py:217 +#: funnel/views/session.py:215 msgid "This project will not be listed as it has no sessions in the schedule" msgstr "" -#: funnel/views/session.py:251 +#: funnel/views/session.py:249 msgid "Something went wrong, please reload and try again" msgstr "कुछ गड़बड़ी हुई है, कृपया दोबारा लोड करें और दोबारा प्रयास करें" -#: funnel/views/siteadmin.py:285 +#: funnel/views/siteadmin.py:326 msgid "Comment(s) successfully reported as spam" msgstr "कमेंट को सफलतापूर्वक स्पैम के रूप में रिपोर्ट कर दिया गया है" -#: funnel/views/siteadmin.py:288 +#: funnel/views/siteadmin.py:329 msgid "There was a problem marking the comments as spam. Try again?" msgstr "" -#: funnel/views/siteadmin.py:303 +#: funnel/views/siteadmin.py:344 msgid "There are no comment reports to review at this time" msgstr "इस समय समीक्षा करने के लिए कोई कमेंट रिपोर्ट नहीं है" -#: funnel/views/siteadmin.py:320 +#: funnel/views/siteadmin.py:361 msgid "You cannot review same comment twice" msgstr "आप एक ही कमेंट की दो बार समीक्षा नहीं कर सकते" -#: funnel/views/siteadmin.py:324 +#: funnel/views/siteadmin.py:365 msgid "You cannot review your own report" msgstr "आप अपने रिपोर्ट की समीक्षा नहीं कर सकते" -#: funnel/views/siteadmin.py:336 +#: funnel/views/siteadmin.py:377 msgid "This comment has already been marked as spam" msgstr "इस कमेंट को पहले ही स्पैम के रूप में चिह्नित किया जा चुका है" -#: funnel/views/ticket_event.py:80 +#: funnel/views/ticket_event.py:79 msgid "This event already exists" msgstr "यह ईवेंट पहले से मौजूद है" -#: funnel/views/ticket_event.py:82 +#: funnel/views/ticket_event.py:81 msgid "New Event" msgstr "नया ईवेंट" -#: funnel/views/ticket_event.py:82 +#: funnel/views/ticket_event.py:81 msgid "Add event" msgstr "ईवेंट जोड़ें" -#: funnel/views/ticket_event.py:99 +#: funnel/views/ticket_event.py:98 msgid "This ticket type already exists" msgstr "यह टिकट टाइप पहले से मौजूद है" -#: funnel/views/ticket_event.py:102 +#: funnel/views/ticket_event.py:101 msgid "New Ticket Type" msgstr "नया टिकट टाइप" -#: funnel/views/ticket_event.py:102 +#: funnel/views/ticket_event.py:101 msgid "Add ticket type" msgstr "टिकट टाइप जोड़ें" -#: funnel/views/ticket_event.py:118 +#: funnel/views/ticket_event.py:117 msgid "This ticket client already exists" msgstr "यह टिकट क्लाइंट पहले से मौजूद है" -#: funnel/views/ticket_event.py:121 +#: funnel/views/ticket_event.py:120 msgid "New Ticket Client" msgstr "नया टिकट क्लाइंट" -#: funnel/views/ticket_event.py:121 +#: funnel/views/ticket_event.py:120 msgid "Add ticket client" msgstr "टिकट क्लाइंट जोड़ें" -#: funnel/views/ticket_event.py:191 +#: funnel/views/ticket_event.py:190 msgid "Edit event" msgstr "ईवेंट संपादित करें" -#: funnel/views/ticket_event.py:201 +#: funnel/views/ticket_event.py:200 msgid "Delete event ‘{title}’? This operation is permanent and cannot be undone" msgstr "" "‘{title}’ ईवेंट मिटाएं? ऐसा करना इसे हमेशा के लिए मिटा देगा और इसे वापस " "पहले जैसा नहीं किया जा सकता" -#: funnel/views/ticket_event.py:205 funnel/views/ticket_event.py:354 +#: funnel/views/ticket_event.py:204 funnel/views/ticket_event.py:355 msgid "This event has been deleted" msgstr "इस ईवेंट को मिटा दिया गया है" @@ -6072,11 +6284,11 @@ msgstr "" msgid "This ticket type has been deleted" msgstr "यह टिकट टाइप मिटा दिया गया है" -#: funnel/views/ticket_event.py:339 +#: funnel/views/ticket_event.py:340 msgid "Edit ticket client" msgstr "टिकट क्लाइंट संपादित करें" -#: funnel/views/ticket_event.py:350 +#: funnel/views/ticket_event.py:351 msgid "" "Delete ticket client ‘{title}’? This operation is permanent and cannot be" " undone" @@ -6084,15 +6296,15 @@ msgstr "" "‘{title}’ टिकट क्लाइंट मिटाएं? ऐसा करना इसे हमेशा के लिए मिटा देगा और इसे" " वापस पहले जैसा नहीं किया जा सकता" -#: funnel/views/ticket_participant.py:162 +#: funnel/views/ticket_participant.py:161 msgid "This participant already exists" msgstr "यह प्रतिभागी पहले से मौजूद है" -#: funnel/views/ticket_participant.py:165 +#: funnel/views/ticket_participant.py:164 msgid "New ticketed participant" msgstr "नए टिकट वाले प्रतिभागी" -#: funnel/views/ticket_participant.py:165 +#: funnel/views/ticket_participant.py:164 msgid "Add participant" msgstr "प्रतिभागी जोड़ें" @@ -6100,7 +6312,7 @@ msgstr "प्रतिभागी जोड़ें" msgid "Edit Participant" msgstr "प्रतिभागी संपादित करें" -#: funnel/views/ticket_participant.py:353 +#: funnel/views/ticket_participant.py:355 msgid "Attendee not found" msgstr "" @@ -6228,6 +6440,10 @@ msgstr "मज़बूत पासवर्ड" msgid "Something went wrong. Please reload and try again" msgstr "कुछ गड़बड़ी हुई है. कृपया दोबारा लोड करें और दोबारा प्रयास करें" +#: funnel/views/api/markdown.py:24 +msgid "Unknown Markdown profile: {profile}" +msgstr "" + #: funnel/views/api/oauth.py:48 msgid "Full access is only available to trusted clients" msgstr "पूरा ऐक्सेस केवल ट्रस्टेड क्लाइंट के लिए ही उपलब्ध है" @@ -6320,27 +6536,27 @@ msgstr "लोगों के लिए चर्चा की जगह" msgid "Read your name and basic account data" msgstr "अपना नाम और मूल अकाउंट डेटा पढ़ें" -#: funnel/views/api/resource.py:483 +#: funnel/views/api/resource.py:486 msgid "Verify user session" msgstr "यूजर सेशन वेरिफाई करें" -#: funnel/views/api/resource.py:503 +#: funnel/views/api/resource.py:506 msgid "Read your email address" msgstr "अपना ईमेल पता पढ़ें" -#: funnel/views/api/resource.py:515 +#: funnel/views/api/resource.py:522 msgid "Read your phone number" msgstr "अपना फोन नंबर पढ़ें" -#: funnel/views/api/resource.py:529 +#: funnel/views/api/resource.py:536 msgid "Access your external account information such as Twitter and Google" msgstr "अपने बाहरी खाते की जानकारी जैसे Twitter और Google को ऐक्सेस करें" -#: funnel/views/api/resource.py:552 +#: funnel/views/api/resource.py:559 msgid "Read the organizations you are a member of" msgstr "उन संगठनों को पढ़ें जिनके आप सदस्य हैं" -#: funnel/views/api/resource.py:567 +#: funnel/views/api/resource.py:574 msgid "Read the list of teams in your organizations" msgstr "अपने संगठनों में टीमों की सूची पढ़ें" @@ -6374,142 +6590,492 @@ msgstr "" msgid "{actor} replied to your comment" msgstr "{actor} ने आपके कमेंट का जवाब दिया" -#: funnel/views/notifications/comment_notification.py:113 -msgid "{actor} commented on a project you are in" +#: funnel/views/notifications/comment_notification.py:113 +msgid "{actor} commented on a project you are in" +msgstr "" + +#: funnel/views/notifications/comment_notification.py:115 +msgid "{actor} commented on your submission" +msgstr "" + +#: funnel/views/notifications/comment_notification.py:117 +msgid "{actor} replied to you" +msgstr "" + +#: funnel/views/notifications/comment_notification.py:124 +msgid "{actor} replied to your comment:" +msgstr "{actor} ने आपके कमेंट का जवाब दिया:" + +#: funnel/views/notifications/comment_notification.py:126 +msgid "{actor} commented on a project you are in:" +msgstr "" + +#: funnel/views/notifications/comment_notification.py:128 +msgid "{actor} commented on your submission:" +msgstr "" + +#: funnel/views/notifications/comment_notification.py:130 +msgid "{actor} replied to you:" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:72 +msgid "{user} was invited to be owner of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:79 +msgid "{user} was invited to be admin of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:91 +msgid "{actor} invited you to be owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:97 +msgid "{actor} invited you to be admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:108 +msgid "You invited {user} to be owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:114 +msgid "You invited {user} to be admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:130 +msgid "{user} was made owner of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:136 +msgid "{user} was made admin of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:147 +msgid "{actor} made you owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:151 +msgid "{actor} made you admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:160 +msgid "You made {user} owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:164 +msgid "You made {user} admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:178 +#: funnel/views/notifications/organization_membership_notification.py:197 +msgid "{user} accepted an invite to be owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:185 +#: funnel/views/notifications/organization_membership_notification.py:204 +msgid "{user} accepted an invite to be admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:216 +msgid "You accepted an invite to be owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:222 +msgid "You accepted an invite to be admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:238 +msgid "{user}’s role was changed to owner of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:245 +msgid "{user}’s role was changed to admin of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:257 +msgid "{actor} changed your role to owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:263 +msgid "{actor} changed your role to admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:274 +msgid "You changed {user}’s role to owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:280 +msgid "You changed {user}’s role to admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:299 +msgid "{user} was removed as owner of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:305 +msgid "{user} was removed as admin of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:316 +msgid "{actor} removed you from owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:320 +msgid "{actor} removed you from admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:329 +msgid "You removed {user} from owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:333 +msgid "You removed {user} from admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:343 +#: funnel/views/notifications/organization_membership_notification.py:444 +msgid "You are receiving this because you are an admin of this organization" +msgstr "आप इसे प्राप्त कर रहे हैं क्योंकि आप इस संगठन के एडमिन हैं" + +#: funnel/views/notifications/organization_membership_notification.py:415 +#: funnel/views/notifications/organization_membership_notification.py:424 +#: funnel/views/notifications/organization_membership_notification.py:434 +#: funnel/views/notifications/project_crew_notification.py:743 +#: funnel/views/notifications/project_crew_notification.py:752 +#: funnel/views/notifications/project_crew_notification.py:762 +msgid "(unknown)" +msgstr "(अज्ञात)" + +#: funnel/views/notifications/organization_membership_notification.py:475 +msgid "You are receiving this because you were an admin of this organization" +msgstr "आप इसे प्राप्त कर रहे हैं क्योंकि आप इस संगठन के एडमिन थे" + +#: funnel/views/notifications/project_crew_notification.py:85 +msgid "{user} was invited to be editor and promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:93 +msgid "{user} was invited to be editor of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:100 +msgid "{user} was invited to be promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:107 +msgid "{user} was invited to join the crew of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:119 +msgid "{actor} invited you to be editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:127 +msgid "{actor} invited you to be editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:133 +msgid "{actor} invited you to be promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:139 +msgid "{actor} invited you to join the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:152 +msgid "You invited {user} to be editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:160 +msgid "You invited {user} to be editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:164 +msgid "You invited {user} to be promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:170 +msgid "You invited {user} to join the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:188 +msgid "{user} accepted an invite to be editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:196 +msgid "{user} accepted an invite to be editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:202 +msgid "{user} accepted an invite to be promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:208 +msgid "{user} accepted an invite to join the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:220 +msgid "You accepted an invite to be editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:228 +msgid "You accepted an invite to be promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:234 +msgid "You accepted an invite to be editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:240 +msgid "You accepted an invite to join the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:256 +msgid "{actor} joined {project} as editor and promoter" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:264 +msgid "{actor} joined {project} as editor" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:269 +msgid "{actor} joined {project} as promoter" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:274 +msgid "{actor} joined the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:278 +msgid "{user} was made editor and promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:286 +msgid "{user} was made editor of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:292 +msgid "{user} was made promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:298 +msgid "{actor} added {user} to the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:309 +msgid "{actor} made you editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:316 +msgid "{actor} made you editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:320 +msgid "{actor} made you promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:324 +msgid "{actor} added you to the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:333 +msgid "You made {user} editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:340 +msgid "You made {user} editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:344 +msgid "You made {user} promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:349 +msgid "You added {user} to the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:358 +msgid "You joined {project} as editor and promoter" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:363 +msgid "You joined {project} as editor" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:367 +msgid "You joined {project} as promoter" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:371 +msgid "You joined the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:385 +msgid "{user} changed their role to editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:394 +msgid "{user} changed their role to editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:401 +msgid "{user} changed their role to promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:408 +msgid "{user} changed their role to crew member of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:414 +msgid "{user}’s role was changed to editor and promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:422 +msgid "{user}’s role was changed to editor of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:429 +msgid "{user}’s role was changed to promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:436 +msgid "{user}’s role was changed to crew member of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:448 +msgid "{actor} changed your role to editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:456 +msgid "{actor} changed your role to editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:462 +msgid "{actor} changed your role to promoter of {project}" msgstr "" -#: funnel/views/notifications/comment_notification.py:115 -msgid "{actor} commented on your submission" +#: funnel/views/notifications/project_crew_notification.py:468 +msgid "{actor} changed your role to crew member of {project}" msgstr "" -#: funnel/views/notifications/comment_notification.py:117 -msgid "{actor} replied to you" +#: funnel/views/notifications/project_crew_notification.py:479 +msgid "You changed {user}’s role to editor and promoter of {project}" msgstr "" -#: funnel/views/notifications/comment_notification.py:124 -msgid "{actor} replied to your comment:" -msgstr "{actor} ने आपके कमेंट का जवाब दिया:" +#: funnel/views/notifications/project_crew_notification.py:487 +msgid "You changed {user}’s role to editor of {project}" +msgstr "" -#: funnel/views/notifications/comment_notification.py:126 -msgid "{actor} commented on a project you are in:" +#: funnel/views/notifications/project_crew_notification.py:493 +msgid "You changed {user}’s role to promoter of {project}" msgstr "" -#: funnel/views/notifications/comment_notification.py:128 -msgid "{actor} commented on your submission:" +#: funnel/views/notifications/project_crew_notification.py:499 +msgid "You changed {user}’s role to crew member of {project}" msgstr "" -#: funnel/views/notifications/comment_notification.py:130 -msgid "{actor} replied to you:" +#: funnel/views/notifications/project_crew_notification.py:510 +msgid "You are now editor and promoter of {project}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:50 -msgid "You have been invited as an owner of {organization} by {actor}" -msgstr "आपको {actor} द्वारा {organization} के ओनर के रूप में आमंत्रित किया गया है" +#: funnel/views/notifications/project_crew_notification.py:516 +msgid "You changed your role to editor of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:56 -msgid "You have been invited as an admin of {organization} by {actor}" +#: funnel/views/notifications/project_crew_notification.py:521 +msgid "You changed your role to promoter of {project}" msgstr "" -"आपको {actor} द्वारा {organization} के एडमिन के रूप में आमंत्रित किया गया " -"है" -#: funnel/views/notifications/organization_membership_notification.py:63 -msgid "You are now an owner of {organization}" -msgstr "अब आप {organization} के ओनर हैं" +#: funnel/views/notifications/project_crew_notification.py:528 +msgid "You changed your role to crew member of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:69 -msgid "You are now an admin of {organization}" -msgstr "अब आप {organization} के एडमिन हैं" +#: funnel/views/notifications/project_crew_notification.py:549 +msgid "{user} resigned as editor and promoter of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:76 -msgid "You have changed your role to owner of {organization}" -msgstr "आपने अपनी भूमिका {organization} के एक ओनर के रूप में बदल दी है" +#: funnel/views/notifications/project_crew_notification.py:555 +msgid "{user} resigned as editor of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:83 -msgid "You have changed your role to an admin of {organization}" -msgstr "आपने अपनी भूमिका {organization} के एक एडमिन के रूप में बदल दी है" +#: funnel/views/notifications/project_crew_notification.py:560 +msgid "{user} resigned as promoter of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:91 -msgid "You were added as an owner of {organization} by {actor}" -msgstr "आपको {actor} द्वारा {organization} के ओनर के रूप में जोड़ा गया था" +#: funnel/views/notifications/project_crew_notification.py:565 +msgid "{user} resigned from the crew of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:98 -msgid "You were added as an admin of {organization} by {actor}" -msgstr "आपको {actor} द्वारा {organization} के एडमिन के रूप में जोड़ा गया था" +#: funnel/views/notifications/project_crew_notification.py:569 +msgid "{user} was removed as editor and promoter of {project} by {actor}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:105 -msgid "Your role was changed to owner of {organization} by {actor}" -msgstr "आपकी भूमिका {actor} द्वारा {organization} के ओनर के रूप में बदल दी गई थी" +#: funnel/views/notifications/project_crew_notification.py:577 +msgid "{user} was removed as editor of {project} by {actor}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:112 -msgid "Your role was changed to admin of {organization} by {actor}" -msgstr "आपकी भूमिका {actor} द्वारा {organization} के एडमिन के रूप में बदल दी गई थी" +#: funnel/views/notifications/project_crew_notification.py:581 +msgid "{user} was removed as promoter of {project} by {actor}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:121 -msgid "{user} was invited to be an owner of {organization} by {actor}" +#: funnel/views/notifications/project_crew_notification.py:587 +msgid "{user} was removed as crew of {project} by {actor}" msgstr "" -"{user} को {actor} द्वारा {organization} का ओनर बनने के लिए आमंत्रित किया " -"गया था" -#: funnel/views/notifications/organization_membership_notification.py:126 -msgid "{user} was invited to be an admin of {organization} by {actor}" +#: funnel/views/notifications/project_crew_notification.py:596 +msgid "{actor} removed you as editor and promoter of {project}" msgstr "" -"{user} को {actor} द्वारा {organization} का एडमिन बनने के लिए आमंत्रित " -"किया गया था" -#: funnel/views/notifications/organization_membership_notification.py:132 -msgid "{user} is now an owner of {organization}" -msgstr "{user} अब {organization} का ओनर है" +#: funnel/views/notifications/project_crew_notification.py:603 +msgid "{actor} removed you as editor of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:137 -msgid "{user} is now an admin of {organization}" -msgstr "{user} अब {organization} का एडमिन है" +#: funnel/views/notifications/project_crew_notification.py:607 +msgid "{actor} removed you as promoter of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:143 -msgid "{user} changed their role to owner of {organization}" -msgstr "{user} ने अपनी भूमिका {organization} के ओनर के रूप में बदल दी" +#: funnel/views/notifications/project_crew_notification.py:611 +msgid "{actor} removed you from the crew of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:149 -msgid "{user} changed their role from owner to admin of {organization}" -msgstr "{user} ने अपनी भूमिका {organization} के एडमिन के रूप में बदल दी" +#: funnel/views/notifications/project_crew_notification.py:619 +msgid "You resigned as editor and promoter of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:156 -msgid "{user} was made an owner of {organization} by {actor}" -msgstr "{user} को {actor} द्वारा {organization} का ओनर बना दिया गया था" +#: funnel/views/notifications/project_crew_notification.py:625 +msgid "You resigned as editor of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:161 -msgid "{user} was made an admin of {organization} by {actor}" -msgstr "{user} को {actor} द्वारा {organization} का एडमिन बना दिया गया था" +#: funnel/views/notifications/project_crew_notification.py:630 +msgid "You resigned as promoter of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:221 -#: funnel/views/notifications/organization_membership_notification.py:230 -#: funnel/views/notifications/organization_membership_notification.py:240 -msgid "(unknown)" -msgstr "(अज्ञात)" +#: funnel/views/notifications/project_crew_notification.py:635 +msgid "You resigned from the crew of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:250 -msgid "You are receiving this because you are an admin of this organization" -msgstr "आप इसे प्राप्त कर रहे हैं क्योंकि आप इस संगठन के एडमिन हैं" +#: funnel/views/notifications/project_crew_notification.py:639 +msgid "You removed {user} as editor and promoter of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:300 -msgid "You are receiving this because you were an admin of this organization" -msgstr "आप इसे प्राप्त कर रहे हैं क्योंकि आप इस संगठन के एडमिन थे" +#: funnel/views/notifications/project_crew_notification.py:646 +msgid "You removed {user} as editor of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:319 -msgid "You removed yourself as an admin of {organization}" -msgstr "आपने खुद को {organization} के एडमिन के रूप में हटा दिया" +#: funnel/views/notifications/project_crew_notification.py:650 +msgid "You removed {user} as promoter of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:320 -msgid "You were removed as an admin of {organization} by {actor}" -msgstr "आपको {actor} द्वारा {organization} के एडमिन के रूप में हटा दिया गया था" +#: funnel/views/notifications/project_crew_notification.py:654 +msgid "You removed {user} from the crew of {project}" +msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:321 -msgid "{user} was removed as an admin of {organization} by {actor}" +#: funnel/views/notifications/project_crew_notification.py:664 +msgid "You are receiving this because you are a crew member of this project" msgstr "" -"{user} को {actor} द्वारा {organization} के एडमिन के रूप में हटा दिया गया " -"था" #: funnel/views/notifications/project_starting_notification.py:24 -#: funnel/views/notifications/rsvp_notification.py:63 +#: funnel/views/notifications/rsvp_notification.py:65 #: funnel/views/notifications/update_notification.py:22 msgid "You are receiving this because you have registered for this project" msgstr "आप इसे प्राप्त कर रहे हैं क्योंकि आपने इस प्रोजेक्ट के लिए रजिस्टर किया है" @@ -6546,32 +7112,32 @@ msgstr "" msgid "Your submission has been received in {project}:" msgstr "" -#: funnel/views/notifications/rsvp_notification.py:72 +#: funnel/views/notifications/rsvp_notification.py:74 msgid "Registration confirmation for {project}" msgstr "{project} के लिए रजिस्ट्रेशन की पुष्टि" -#: funnel/views/notifications/rsvp_notification.py:83 -#: funnel/views/notifications/rsvp_notification.py:131 +#: funnel/views/notifications/rsvp_notification.py:85 +#: funnel/views/notifications/rsvp_notification.py:133 msgid "View project" msgstr "प्रोजेक्ट देखें" -#: funnel/views/notifications/rsvp_notification.py:91 +#: funnel/views/notifications/rsvp_notification.py:93 msgid "You have registered for {project}. Next session: {datetime}." msgstr "" -#: funnel/views/notifications/rsvp_notification.py:93 +#: funnel/views/notifications/rsvp_notification.py:95 msgid "You have registered for {project}" msgstr "" -#: funnel/views/notifications/rsvp_notification.py:114 +#: funnel/views/notifications/rsvp_notification.py:116 msgid "You are receiving this because you had registered for this project" msgstr "आप इसे प्राप्त कर रहे हैं क्योंकि आपने इस प्रोजेक्ट के लिए रजिस्टर किया था" -#: funnel/views/notifications/rsvp_notification.py:120 +#: funnel/views/notifications/rsvp_notification.py:122 msgid "Registration cancelled for {project}" msgstr "{project} के लिए रजिस्ट्रेशन रद्द किया गया" -#: funnel/views/notifications/rsvp_notification.py:137 +#: funnel/views/notifications/rsvp_notification.py:139 msgid "You have cancelled your registration for {project}" msgstr "आपने {project} के लिए अपना रजिस्ट्रेशन रद्द कर दिया है" @@ -7362,7 +7928,7 @@ msgstr "" #~ msgid "Schedule JSON" #~ msgstr "कार्यक्रम की JSON फाइल" -#~ msgid "Invalid transition for this project's schedule" +#~ msgid "Invalid transition for this project’s schedule" #~ msgstr "इस प्रोजेक्ट के शेड्यूल के लिए अमान्य परिवर्तन" #~ msgid "Attend" @@ -7429,7 +7995,7 @@ msgstr "" #~ " करता है" #~ msgid "" -#~ "When the user's data changes, Lastuser" +#~ "When the user’s data changes, Lastuser" #~ " will POST a notice to this " #~ "URL. Other notices may be posted " #~ "too" @@ -8281,3 +8847,394 @@ msgstr "" #~ msgid "Read your name and basic profile data" #~ msgstr "अपना नाम और मूल प्रोफाइल डेटा पढ़ें" + +#~ msgid "Type" +#~ msgstr "प्रकार" + +#~ msgid "Work" +#~ msgstr "व्यवसाय" + +#~ msgid "Other" +#~ msgstr "अन्य" + +#~ msgid "This phone number has already been claimed" +#~ msgstr "यह फोन नंबर पहले से ही इस्तेमाल में है" + +#~ msgid "This email address has been claimed by someone else" +#~ msgstr "इस ईमेल पते का इस्तेमाल किसी और के द्वारा किया जा चुका है" + +#~ msgid "Organizations I manage" +#~ msgstr "मेरे द्वारा प्रबंधित संगठन" + +#~ msgid "When I cancel my registration" +#~ msgstr "जब मैं अपना पंजीकरण रद्द करूं" + +#~ msgid "When there is a new comment on a project or proposal I’m in" +#~ msgstr "जब मेरे द्वारा शामिल किसी प्रोजेक्ट या प्रस्ताव में कोई नई कमेंट की जाए" + +#~ msgid "When someone replies to my comment" +#~ msgstr "जब कोई मेरे मेरे कमेंट का जवाब दे" + +#~ msgid "When a project crew member is added, or roles change" +#~ msgstr "जब प्रोजेक्ट के दल में किसी सदस्य को जोड़ा जाए या भूमिका बदली जाए" + +#~ msgid "Crew members have access to the project’s controls" +#~ msgstr "दल के सदस्य के पास प्रोजेक्ट के नियंत्रणों को बदलने का एक्सेस होता है" + +#~ msgid "When a project crew member is removed, including me" +#~ msgstr "मेरे सहित, जब प्रोजेक्ट के दल के किसी सदस्य को हटाया जाए" + +#~ msgid "When organization admins change" +#~ msgstr "जब संगठन का कोई एडमिन बदले" + +#~ msgid "Organization admins control all projects under the organization" +#~ msgstr "संगठन के एडमिन उसमें मौजूद सभी प्रोजेक्ट का नियंत्रण रखते हैं" + +#~ msgid "When an organization admin is removed, including me" +#~ msgstr "मेरे सहित, जब संगठन के किसी एडमिन को हटाया जाए" + +#~ msgid "This must be a shareable URL for a single file in Google Drive" +#~ msgstr "" + +#~ msgid "" +#~ "It’s 2022, and the world as we " +#~ "know it is slightly upturned. Meeting" +#~ " new people and geeking-out about " +#~ "your passion has become harder than " +#~ "it used to be. These special " +#~ "interactions that drive us to do " +#~ "new things and explore new ideas " +#~ "also need a new place. It’s time" +#~ " to rebuild everything. Join us." +#~ msgstr "" + +#~ msgid "" +#~ "\n" +#~ " %(actor)s has added you to ‘%(project)s’ as a crew member.\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " %(actor)s ने आपको ‘%(project)s’ दल के सदस्य के रूप में जोड़ा है.\n" +#~ " " + +#~ msgid "" +#~ "\n" +#~ " %(actor)s has invited you to join ‘%(project)s’ as a crew member.\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " %(actor)s ने आपको दल के सदस्य" +#~ " के रूप में ‘%(project)s’ में शामिल" +#~ " होने के लिए आमंत्रित किया है.\n" +#~ "" +#~ " " + +#~ msgid "Accept or decline invite" +#~ msgstr "आमंत्रण को स्वीकार करें या अस्वीकारें" + +#~ msgid "" +#~ "\n" +#~ " %(actor)s has removed you as a crew member from ‘%(project)s’.\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " %(actor)s ने आपको ‘%(project)s’ के दल के सदस्य से हटा दिया है.\n" +#~ " " + +#~ msgid "Search the site" +#~ msgstr "साइट पर खोजें" + +#~ msgid "Free updates" +#~ msgstr "" + +#~ msgid "Free" +#~ msgstr "" + +#~ msgid "Get Tickets" +#~ msgstr "" + +#~ msgid "Tickets available" +#~ msgstr "" + +#~ msgid "More options" +#~ msgstr "" + +#~ msgid "Tomorrow " +#~ msgstr "" + +#~ msgid "" +#~ "\n" +#~ " Only %(total_comments)s comment for \"%(query)s\"" +#~ msgstr "" +#~ "\n" +#~ " “%(query)s” के लिए केवल %(total_comments)s कमेंट" + +#~ msgid "" +#~ "\n" +#~ " Only %(total_comments)s comment" +#~ msgstr "" +#~ "\n" +#~ " केवल %(total_comments)s कमेंट" + +#~ msgid "Add proposal video" +#~ msgstr "प्रस्ताव का वीडियो जोड़ें" + +#~ msgid "Select a relevant label" +#~ msgstr "" + +#~ msgid "Link a video" +#~ msgstr "" + +#~ msgid "" +#~ "You have submitted a new proposal " +#~ "%(proposal)s to the project " +#~ "%(project)s" +#~ msgstr "" +#~ "आपने %(project)s प्रोजेक्ट में एक " +#~ "नया प्रस्ताव %(proposal)s भेजा है" + +#~ msgid "View proposal" +#~ msgstr "प्रस्ताव देखें" + +#~ msgid "You have been added to {project} as a crew member" +#~ msgstr "आपको दल के सदस्य के रूप में {project} में जोड़ा गया है" + +#~ msgid "You have been invited to {project} as a crew member" +#~ msgstr "आपको दल के सदस्य के रूप में {project} में आमंत्रित किया गया है" + +#~ msgid "You have been removed from {project} as a crew member" +#~ msgstr "आपको दल के सदस्य के रूप में {project} से हटा दिया गया है" + +#~ msgid "You have been invited as an owner of {organization} by {actor}" +#~ msgstr "" +#~ "आपको {actor} द्वारा {organization} के " +#~ "ओनर के रूप में आमंत्रित किया गया" +#~ " है" + +#~ msgid "You have been invited as an admin of {organization} by {actor}" +#~ msgstr "" +#~ "आपको {actor} द्वारा {organization} के " +#~ "एडमिन के रूप में आमंत्रित किया गया" +#~ " है" + +#~ msgid "You are now an owner of {organization}" +#~ msgstr "अब आप {organization} के ओनर हैं" + +#~ msgid "You are now an admin of {organization}" +#~ msgstr "अब आप {organization} के एडमिन हैं" + +#~ msgid "You have changed your role to owner of {organization}" +#~ msgstr "आपने अपनी भूमिका {organization} के एक ओनर के रूप में बदल दी है" + +#~ msgid "You have changed your role to an admin of {organization}" +#~ msgstr "आपने अपनी भूमिका {organization} के एक एडमिन के रूप में बदल दी है" + +#~ msgid "You were added as an owner of {organization} by {actor}" +#~ msgstr "आपको {actor} द्वारा {organization} के ओनर के रूप में जोड़ा गया था" + +#~ msgid "You were added as an admin of {organization} by {actor}" +#~ msgstr "आपको {actor} द्वारा {organization} के एडमिन के रूप में जोड़ा गया था" + +#~ msgid "Your role was changed to owner of {organization} by {actor}" +#~ msgstr "" +#~ "आपकी भूमिका {actor} द्वारा {organization} " +#~ "के ओनर के रूप में बदल दी गई" +#~ " थी" + +#~ msgid "Your role was changed to admin of {organization} by {actor}" +#~ msgstr "" +#~ "आपकी भूमिका {actor} द्वारा {organization} " +#~ "के एडमिन के रूप में बदल दी " +#~ "गई थी" + +#~ msgid "{user} was invited to be an owner of {organization} by {actor}" +#~ msgstr "" +#~ "{user} को {actor} द्वारा {organization} " +#~ "का ओनर बनने के लिए आमंत्रित किया" +#~ " गया था" + +#~ msgid "{user} was invited to be an admin of {organization} by {actor}" +#~ msgstr "" +#~ "{user} को {actor} द्वारा {organization} " +#~ "का एडमिन बनने के लिए आमंत्रित किया" +#~ " गया था" + +#~ msgid "{user} is now an owner of {organization}" +#~ msgstr "{user} अब {organization} का ओनर है" + +#~ msgid "{user} is now an admin of {organization}" +#~ msgstr "{user} अब {organization} का एडमिन है" + +#~ msgid "{user} changed their role to owner of {organization}" +#~ msgstr "{user} ने अपनी भूमिका {organization} के ओनर के रूप में बदल दी" + +#~ msgid "{user} changed their role from owner to admin of {organization}" +#~ msgstr "{user} ने अपनी भूमिका {organization} के एडमिन के रूप में बदल दी" + +#~ msgid "{user} was made an owner of {organization} by {actor}" +#~ msgstr "{user} को {actor} द्वारा {organization} का ओनर बना दिया गया था" + +#~ msgid "{user} was made an admin of {organization} by {actor}" +#~ msgstr "{user} को {actor} द्वारा {organization} का एडमिन बना दिया गया था" + +#~ msgid "You removed yourself as an admin of {organization}" +#~ msgstr "आपने खुद को {organization} के एडमिन के रूप में हटा दिया" + +#~ msgid "You were removed as an admin of {organization} by {actor}" +#~ msgstr "आपको {actor} द्वारा {organization} के एडमिन के रूप में हटा दिया गया था" + +#~ msgid "{user} was removed as an admin of {organization} by {actor}" +#~ msgstr "" +#~ "{user} को {actor} द्वारा {organization} " +#~ "के एडमिन के रूप में हटा दिया " +#~ "गया था" + +#~ msgid "" +#~ "\n" +#~ " Only %(count)s comment for “%(query)s”" +#~ msgstr "" + +#~ msgid "" +#~ "\n" +#~ " Only %(count)s comment" +#~ msgstr "" + +#~ msgid "" +#~ "Usernames can only have alphabets, " +#~ "numbers and dashes (except at the " +#~ "ends)" +#~ msgstr "यूजरनेम में सिर्फ वर्णमाला, नंबर और डैश (अंत में छोड़कर) हो सकते हैं" + +#~ msgid "" +#~ "Single word that can contain letters," +#~ " numbers and dashes. You need a " +#~ "username to have a public account " +#~ "page" +#~ msgstr "" +#~ "एक ही शब्द जिसमें कि अक्षर, नंबर" +#~ " और डैश हों. आपको पब्लिक अकाउंट " +#~ "बनाने के लिए यूजरनेम की आवश्यकता " +#~ "है" + +#~ msgid "" +#~ "A short name for your organization’s " +#~ "account page. Single word containing " +#~ "letters, numbers and dashes only. Pick" +#~ " something permanent: changing it will " +#~ "break existing links from around the " +#~ "web" +#~ msgstr "" +#~ "आपके संगठन के अकाउंट पेज के लिए" +#~ " एक संक्षिप्त नाम. एक शब्द जिसमें " +#~ "केवल अक्षर, नंबर और डैश ही शामिल" +#~ " हों. कुछ स्थाई सा नाम रखें: " +#~ "इसे बदलने से वेब पर मौजूद पुराने" +#~ " लिंक काम नहीं करेंगे" + +#~ msgid "Names can only have letters, numbers and dashes (except at the ends)" +#~ msgstr "नाम में केवल अक्षर, नंबर और डैश (अंत में छोड़कर) हो सकते हैं" + +#~ msgid "" +#~ "A short name for mentioning you " +#~ "with @username, and the URL to " +#~ "your account’s page. Single word " +#~ "containing letters, numbers and dashes " +#~ "only. Pick something permanent: changing " +#~ "it will break existing links from " +#~ "around the web" +#~ msgstr "" +#~ "@username और आपके अकाउंट पेज की " +#~ "URL के साथ लगाने के लिए एक " +#~ "उपनाम. एक शब्द जिसमें केवल अक्षर, " +#~ "नंबर और डैश मौजूद हों. कुछ स्थाई" +#~ " सा नाम रखें: इसे बदलने से वेब" +#~ " पर मौजूद पुराने लिंक काम नहीं " +#~ "करेंगे" + +#~ msgid "Registration menu" +#~ msgstr "" + +#~ msgid "Cancel registration" +#~ msgstr "रजिस्ट्रेशन रद्द करें" + +#~ msgid "Get a subscription" +#~ msgstr "" + +#~ msgid "You and one other have registered" +#~ msgstr "आपने और एक अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and one other are following" +#~ msgstr "" + +#~ msgid "You and two others have registered" +#~ msgstr "आपने और दो अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and two others are following" +#~ msgstr "" + +#~ msgid "You and three others have registered" +#~ msgstr "आपने और तीन अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and three others are following" +#~ msgstr "" + +#~ msgid "You and four others have registered" +#~ msgstr "आपने और चार अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and four others are following" +#~ msgstr "" + +#~ msgid "You and five others have registered" +#~ msgstr "आपने और पांच अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and five others are following" +#~ msgstr "" + +#~ msgid "You and six others have registered" +#~ msgstr "आपने और छे अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and six others are following" +#~ msgstr "" + +#~ msgid "You and seven others have registered" +#~ msgstr "आपने और सात अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and seven others are following" +#~ msgstr "" + +#~ msgid "You and eight others have registered" +#~ msgstr "आपने और आठ अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and eight others are following" +#~ msgstr "" + +#~ msgid "You and nine others have registered" +#~ msgstr "आपने और नौ अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and nine others are following" +#~ msgstr "" + +#~ msgid "You and {num} others have registered" +#~ msgstr "आपने और {num} अन्य व्यक्ति ने रजिस्टर कर लिया है" + +#~ msgid "You and {num} others are following" +#~ msgstr "" + +#~ msgid "Join free" +#~ msgstr "" + +#~ msgid "A single word that is uniquely yours, for your account page" +#~ msgstr "" + +#~ msgid "" +#~ "A short name for mentioning you " +#~ "with @username, and the URL to " +#~ "your account’s page. Single word " +#~ "containing letters, numbers and underscores" +#~ " only. Pick something permanent: changing" +#~ " it will break existing links from" +#~ " around the web" +#~ msgstr "" + +#~ msgid "Names can only have letters, numbers and underscores" +#~ msgstr "" diff --git a/funnel/translations/messages.pot b/funnel/translations/messages.pot index 778f06f4a..fe36c9207 100644 --- a/funnel/translations/messages.pot +++ b/funnel/translations/messages.pot @@ -1,21 +1,21 @@ # Translations template for PROJECT. -# Copyright (C) 2022 ORGANIZATION +# Copyright (C) 2023 ORGANIZATION # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2022. +# FIRST AUTHOR , 2023. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-11-15 18:44+0530\n" +"POT-Creation-Date: 2023-04-23 06:11+0530\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.11.0\n" +"Generated-By: Babel 2.12.1\n" #: funnel/registry.py:77 msgid "A Bearer token is required in the Authorization header" @@ -33,11 +33,11 @@ msgstr "" msgid "Access token has expired" msgstr "" -#: funnel/registry.py:103 +#: funnel/registry.py:102 msgid "Token does not provide access to this resource" msgstr "" -#: funnel/registry.py:107 +#: funnel/registry.py:106 msgid "This resource can only be accessed by trusted clients" msgstr "" @@ -59,31 +59,31 @@ msgstr "" msgid "An error occured when submitting the form" msgstr "" -#: funnel/assets/js/form.js:23 +#: funnel/assets/js/form.js:24 msgid "Saving" msgstr "" -#: funnel/assets/js/form.js:41 +#: funnel/assets/js/form.js:42 msgid "Changes saved but not published" msgstr "" -#: funnel/assets/js/form.js:58 +#: funnel/assets/js/form.js:59 msgid "You have unsaved changes on this page. Do you want to leave this page?" msgstr "" -#: funnel/assets/js/form.js:71 +#: funnel/assets/js/form.js:72 msgid "These changes have not been published yet" msgstr "" -#: funnel/assets/js/project_header.js:51 +#: funnel/assets/js/project_header.js:52 msgid "The server is experiencing difficulties. Try again in a few minutes" msgstr "" -#: funnel/assets/js/project_header.js:58 +#: funnel/assets/js/project_header.js:59 msgid "This device has no internet connection" msgstr "" -#: funnel/assets/js/project_header.js:60 +#: funnel/assets/js/project_header.js:61 msgid "" "Unable to connect. If this device is behind a firewall or using any " "script blocking extension (like Privacy Badger), please ensure your " @@ -121,6 +121,7 @@ msgid "Today" msgstr "" #: funnel/assets/js/utils/helper.js:237 +#: funnel/templates/room_updates.html.jinja2:30 msgid "Tomorrow" msgstr "" @@ -133,87 +134,87 @@ msgstr "" msgid "In %d days" msgstr "" -#: funnel/assets/js/utils/helper.js:316 funnel/assets/js/utils/helper.js:326 +#: funnel/assets/js/utils/helper.js:343 funnel/assets/js/utils/helper.js:353 msgid "Link copied" msgstr "" -#: funnel/assets/js/utils/helper.js:317 funnel/assets/js/utils/helper.js:328 +#: funnel/assets/js/utils/helper.js:344 funnel/assets/js/utils/helper.js:355 msgid "Could not copy link" msgstr "" -#: funnel/forms/account.py:55 +#: funnel/forms/account.py:58 msgid "English" msgstr "" -#: funnel/forms/account.py:56 +#: funnel/forms/account.py:59 msgid "Hindi (beta; incomplete)" msgstr "" -#: funnel/forms/account.py:63 +#: funnel/forms/account.py:66 msgid "" "This password is too simple. Add complexity by making it longer and using" " a mix of upper and lower case letters, numbers and symbols" msgstr "" -#: funnel/forms/account.py:145 +#: funnel/forms/account.py:148 msgid "This password was found in a breached password list and is not safe to use" msgstr "" -#: funnel/forms/account.py:163 funnel/forms/account.py:190 -#: funnel/forms/login.py:139 +#: funnel/forms/account.py:166 funnel/forms/account.py:193 +#: funnel/forms/login.py:146 msgid "Password" msgstr "" -#: funnel/forms/account.py:174 funnel/forms/account.py:372 -#: funnel/forms/login.py:37 +#: funnel/forms/account.py:177 funnel/forms/account.py:375 +#: funnel/forms/login.py:39 msgid "Incorrect password" msgstr "" -#: funnel/forms/account.py:231 funnel/forms/account.py:291 -#: funnel/forms/login.py:124 +#: funnel/forms/account.py:234 funnel/forms/account.py:294 +#: funnel/forms/login.py:131 msgid "Phone number or email address" msgstr "" -#: funnel/forms/account.py:244 +#: funnel/forms/account.py:247 msgid "Could not find a user with that id" msgstr "" -#: funnel/forms/account.py:258 funnel/forms/account.py:302 -#: funnel/forms/account.py:348 +#: funnel/forms/account.py:261 funnel/forms/account.py:305 +#: funnel/forms/account.py:351 msgid "New password" msgstr "" -#: funnel/forms/account.py:268 funnel/forms/account.py:312 -#: funnel/forms/account.py:358 +#: funnel/forms/account.py:271 funnel/forms/account.py:315 +#: funnel/forms/account.py:361 msgid "Confirm password" msgstr "" -#: funnel/forms/account.py:293 +#: funnel/forms/account.py:296 msgid "Please reconfirm your phone number, email address or username" msgstr "" -#: funnel/forms/account.py:326 +#: funnel/forms/account.py:329 msgid "This does not match the user the reset code is for" msgstr "" -#: funnel/forms/account.py:340 +#: funnel/forms/account.py:343 msgid "Current password" msgstr "" -#: funnel/forms/account.py:370 +#: funnel/forms/account.py:373 msgid "Not logged in" msgstr "" -#: funnel/forms/account.py:378 funnel/forms/account.py:483 +#: funnel/forms/account.py:381 funnel/forms/account.py:482 msgid "This is required" msgstr "" -#: funnel/forms/account.py:380 +#: funnel/forms/account.py:383 msgid "This is too long" msgstr "" -#: funnel/forms/account.py:383 -msgid "Usernames can only have alphabets, numbers and dashes (except at the ends)" +#: funnel/forms/account.py:386 +msgid "Usernames can only have alphabets, numbers and underscores" msgstr "" #: funnel/forms/account.py:389 @@ -236,125 +237,88 @@ msgstr "" msgid "This is your name, not of your organization" msgstr "" -#: funnel/forms/account.py:412 funnel/forms/account.py:482 +#: funnel/forms/account.py:412 funnel/forms/account.py:481 #: funnel/forms/organization.py:37 msgid "Username" msgstr "" #: funnel/forms/account.py:413 -msgid "" -"Single word that can contain letters, numbers and dashes. You need a " -"username to have a public account page" +msgid "A single word that is uniquely yours, for your account page and @mentions" msgstr "" -#: funnel/forms/account.py:429 funnel/forms/project.py:93 +#: funnel/forms/account.py:428 funnel/forms/project.py:98 msgid "Timezone" msgstr "" -#: funnel/forms/account.py:430 +#: funnel/forms/account.py:429 msgid "" "Where in the world are you? Dates and times will be shown in your local " "timezone" msgstr "" -#: funnel/forms/account.py:438 +#: funnel/forms/account.py:437 msgid "Use your device’s timezone" msgstr "" -#: funnel/forms/account.py:440 +#: funnel/forms/account.py:439 msgid "Locale" msgstr "" -#: funnel/forms/account.py:441 +#: funnel/forms/account.py:440 msgid "Your preferred UI language" msgstr "" -#: funnel/forms/account.py:444 +#: funnel/forms/account.py:443 msgid "Use your device’s language" msgstr "" -#: funnel/forms/account.py:459 +#: funnel/forms/account.py:458 msgid "I understand that deletion is permanent and my account cannot be recovered" msgstr "" -#: funnel/forms/account.py:462 funnel/forms/account.py:471 +#: funnel/forms/account.py:461 funnel/forms/account.py:470 msgid "You must accept this" msgstr "" -#: funnel/forms/account.py:465 +#: funnel/forms/account.py:464 msgid "" "I understand that deleting my account will remove personal details such " "as my name and contact details, but not messages sent to other users, or " "public content such as comments, job posts and submissions to projects" msgstr "" -#: funnel/forms/account.py:470 +#: funnel/forms/account.py:469 msgid "Public content must be deleted individually" msgstr "" -#: funnel/forms/account.py:507 +#: funnel/forms/account.py:506 msgid "This email address is pending verification" msgstr "" -#: funnel/forms/account.py:519 funnel/forms/account.py:549 +#: funnel/forms/account.py:518 funnel/forms/account.py:538 msgid "Email address" msgstr "" -#: funnel/forms/account.py:533 -msgid "Type" -msgstr "" - -#: funnel/forms/account.py:537 funnel/templates/layout.html.jinja2:92 -#: funnel/templates/layout.html.jinja2:96 -#: funnel/templates/macros.html.jinja2:536 -msgid "Home" -msgstr "" - -#: funnel/forms/account.py:538 -msgid "Work" -msgstr "" - -#: funnel/forms/account.py:539 -msgid "Other" -msgstr "" - -#: funnel/forms/account.py:568 funnel/forms/account.py:616 -#: funnel/forms/sync_ticket.py:128 +#: funnel/forms/account.py:557 funnel/forms/account.py:584 +#: funnel/forms/sync_ticket.py:138 msgid "Phone number" msgstr "" -#: funnel/forms/account.py:571 +#: funnel/forms/account.py:563 msgid "Mobile numbers only, in Indian or international format" msgstr "" -#: funnel/forms/account.py:576 +#: funnel/forms/account.py:570 msgid "Send notifications by SMS" msgstr "" -#: funnel/forms/account.py:577 +#: funnel/forms/account.py:571 msgid "" "Unsubscribe anytime, and control what notifications are sent from the " "Notifications tab under account settings" msgstr "" -#: funnel/forms/account.py:590 funnel/forms/login.py:43 -#: funnel/transports/sms/send.py:186 -msgid "This phone number cannot receive SMS messages" -msgstr "" - -#: funnel/forms/account.py:594 -msgid "This does not appear to be a valid phone number" -msgstr "" - -#: funnel/forms/account.py:601 -msgid "You have already registered this phone number" -msgstr "" - -#: funnel/forms/account.py:605 -msgid "This phone number has already been claimed" -msgstr "" - -#: funnel/forms/account.py:630 +#: funnel/forms/account.py:598 msgid "Report type" msgstr "" @@ -381,7 +345,7 @@ msgid "A description to help users recognize your application" msgstr "" #: funnel/forms/auth_client.py:52 -#: funnel/templates/account_organizations.html.jinja2:52 +#: funnel/templates/account_organizations.html.jinja2:57 #: funnel/templates/auth_client.html.jinja2:41 #: funnel/templates/auth_client_index.html.jinja2:16 #: funnel/templates/js/membership.js.jinja2:99 @@ -458,13 +422,13 @@ msgstr "" msgid "Permission ‘{perm}’ is malformed" msgstr "" -#: funnel/forms/auth_client.py:177 funnel/forms/membership.py:22 -#: funnel/forms/membership.py:45 funnel/forms/proposal.py:197 +#: funnel/forms/auth_client.py:177 funnel/forms/membership.py:23 +#: funnel/forms/membership.py:46 funnel/forms/proposal.py:198 #: funnel/templates/siteadmin_comments.html.jinja2:51 msgid "User" msgstr "" -#: funnel/forms/auth_client.py:179 funnel/forms/organization.py:108 +#: funnel/forms/auth_client.py:179 funnel/forms/organization.py:104 msgid "Lookup a user by their username or email address" msgstr "" @@ -485,62 +449,99 @@ msgstr "" msgid "Unknown team" msgstr "" -#: funnel/forms/comment.py:29 funnel/templates/submission.html.jinja2:43 +#: funnel/forms/comment.py:29 funnel/templates/submission.html.jinja2:94 msgid "Get notifications" msgstr "" -#: funnel/forms/helpers.py:79 -msgid "This email address has been claimed by someone else" +#: funnel/forms/helpers.py:89 +msgid "This email address is linked to another account" msgstr "" -#: funnel/forms/helpers.py:82 +#: funnel/forms/helpers.py:92 msgid "" "This email address is already registered. You may want to try logging in " "or resetting your password" msgstr "" -#: funnel/forms/helpers.py:89 +#: funnel/forms/helpers.py:99 msgid "This does not appear to be a valid email address" msgstr "" -#: funnel/forms/helpers.py:93 +#: funnel/forms/helpers.py:103 msgid "" "The domain name of this email address is missing a DNS MX record. We " "require an MX record as missing MX is a strong indicator of spam. Please " "ask your tech person to add MX to DNS" msgstr "" -#: funnel/forms/helpers.py:101 +#: funnel/forms/helpers.py:111 msgid "You have already registered this email address" msgstr "" -#: funnel/forms/helpers.py:107 +#: funnel/forms/helpers.py:117 msgid "" "This email address appears to be having temporary problems with receiving" " email. Please use another if necessary" msgstr "" -#: funnel/forms/helpers.py:115 +#: funnel/forms/helpers.py:126 msgid "" "This email address is no longer valid. If you believe this to be " "incorrect, email {support} asking for the address to be activated" msgstr "" -#: funnel/forms/helpers.py:128 +#: funnel/forms/helpers.py:133 funnel/forms/login.py:38 +#: funnel/views/account.py:524 +msgid "This email address has been blocked from use" +msgstr "" + +#: funnel/forms/helpers.py:145 msgid "" "You or someone else has made an account with this email address but has " "not confirmed it. Do you need to reset your password?" msgstr "" -#: funnel/forms/helpers.py:141 funnel/forms/project.py:163 +#: funnel/forms/helpers.py:178 funnel/forms/login.py:45 +#: funnel/transports/sms/send.py:226 +msgid "This phone number cannot receive SMS messages" +msgstr "" + +#: funnel/forms/helpers.py:182 funnel/forms/helpers.py:203 +msgid "This does not appear to be a valid phone number" +msgstr "" + +#: funnel/forms/helpers.py:193 +msgid "This phone number is linked to another account" +msgstr "" + +#: funnel/forms/helpers.py:196 +msgid "" +"This phone number is already registered. You may want to try logging in " +"or resetting your password" +msgstr "" + +#: funnel/forms/helpers.py:207 +msgid "You have already registered this phone number" +msgstr "" + +#: funnel/forms/helpers.py:211 funnel/forms/login.py:46 +#: funnel/views/account.py:552 +msgid "This phone number has been blocked from use" +msgstr "" + +#: funnel/forms/helpers.py:225 funnel/forms/project.py:168 msgid "A https:// URL is required" msgstr "" -#: funnel/forms/helpers.py:142 +#: funnel/forms/helpers.py:226 msgid "Images must be hosted at images.hasgeek.com" msgstr "" -#: funnel/forms/label.py:18 funnel/forms/project.py:306 +#: funnel/forms/helpers.py:237 funnel/forms/helpers.py:247 +msgid "This video URL is not supported" +msgstr "" + +#: funnel/forms/label.py:18 funnel/forms/project.py:312 msgid "Label" msgstr "" @@ -568,125 +569,138 @@ msgstr "" msgid "Option" msgstr "" -#: funnel/forms/login.py:36 -msgid "This email address has been blocked from use" -msgstr "" - -#: funnel/forms/login.py:38 +#: funnel/forms/login.py:40 msgid "" "This account could not be identified. Try with a phone number or email " "address" msgstr "" -#: funnel/forms/login.py:41 +#: funnel/forms/login.py:43 msgid "OTP is incorrect" msgstr "" -#: funnel/forms/login.py:42 +#: funnel/forms/login.py:44 msgid "That does not appear to be a valid login session" msgstr "" -#: funnel/forms/login.py:70 +#: funnel/forms/login.py:74 msgid "Password is required" msgstr "" -#: funnel/forms/login.py:127 +#: funnel/forms/login.py:134 msgid "A phone number or email address is required" msgstr "" -#: funnel/forms/login.py:144 +#: funnel/forms/login.py:151 #, python-format msgid "Password must be under %(max)s characters" msgstr "" -#: funnel/forms/login.py:248 +#: funnel/forms/login.py:260 msgid "Session id" msgstr "" -#: funnel/forms/login.py:266 funnel/forms/login.py:302 -#: funnel/views/account.py:260 +#: funnel/forms/login.py:278 funnel/forms/login.py:314 +#: funnel/views/account.py:270 msgid "OTP" msgstr "" -#: funnel/forms/login.py:267 funnel/forms/login.py:303 +#: funnel/forms/login.py:279 funnel/forms/login.py:315 msgid "One-time password sent to your device" msgstr "" -#: funnel/forms/login.py:291 funnel/forms/profile.py:68 +#: funnel/forms/login.py:303 funnel/forms/profile.py:68 msgid "Your name" msgstr "" -#: funnel/forms/login.py:292 +#: funnel/forms/login.py:304 msgid "" "This account is for you as an individual. We’ll make one for your " "organization later" msgstr "" -#: funnel/forms/membership.py:23 funnel/forms/membership.py:46 +#: funnel/forms/membership.py:24 funnel/forms/membership.py:47 msgid "Please select a user" msgstr "" -#: funnel/forms/membership.py:24 funnel/forms/membership.py:47 -#: funnel/forms/proposal.py:198 +#: funnel/forms/membership.py:25 funnel/forms/membership.py:48 +#: funnel/forms/proposal.py:199 msgid "Find a user by their name or email address" msgstr "" -#: funnel/forms/membership.py:27 +#: funnel/forms/membership.py:28 msgid "Access level" msgstr "" -#: funnel/forms/membership.py:33 +#: funnel/forms/membership.py:34 msgid "Admin (can manage projects, but can’t add or remove other admins)" msgstr "" -#: funnel/forms/membership.py:35 +#: funnel/forms/membership.py:36 msgid "Owner (can also manage other owners and admins)" msgstr "" -#: funnel/forms/membership.py:50 funnel/models/comment.py:363 +#: funnel/forms/membership.py:51 funnel/models/comment.py:370 #: funnel/templates/js/membership.js.jinja2:119 msgid "Editor" msgstr "" -#: funnel/forms/membership.py:52 +#: funnel/forms/membership.py:53 msgid "Can edit project details, proposal guidelines, schedule, labels and venues" msgstr "" -#: funnel/forms/membership.py:57 funnel/models/comment.py:365 +#: funnel/forms/membership.py:58 funnel/models/comment.py:372 #: funnel/templates/js/membership.js.jinja2:120 msgid "Promoter" msgstr "" -#: funnel/forms/membership.py:59 +#: funnel/forms/membership.py:60 msgid "Can manage participants and see contact info" msgstr "" -#: funnel/forms/membership.py:62 funnel/templates/js/membership.js.jinja2:121 +#: funnel/forms/membership.py:63 funnel/templates/js/membership.js.jinja2:121 msgid "Usher" msgstr "" -#: funnel/forms/membership.py:64 +#: funnel/forms/membership.py:65 msgid "Can check-in a participant using their badge at a physical event" msgstr "" -#: funnel/forms/membership.py:83 +#: funnel/forms/membership.py:70 funnel/forms/proposal.py:203 +#: funnel/templates/js/membership.js.jinja2:24 +msgid "Role" +msgstr "" + +#: funnel/forms/membership.py:71 +msgid "Optional – Name this person’s role" +msgstr "" + +#: funnel/forms/membership.py:79 +msgid "Select one or more roles" +msgstr "" + +#: funnel/forms/membership.py:89 msgid "Choice" msgstr "" -#: funnel/forms/membership.py:84 funnel/models/membership_mixin.py:64 +#: funnel/forms/membership.py:90 funnel/models/membership_mixin.py:67 +#: funnel/templates/membership_invite_actions.html.jinja2:15 msgid "Accept" msgstr "" -#: funnel/forms/membership.py:84 +#: funnel/forms/membership.py:90 +#: funnel/templates/membership_invite_actions.html.jinja2:16 msgid "Decline" msgstr "" -#: funnel/forms/membership.py:85 +#: funnel/forms/membership.py:91 msgid "Please make a choice" msgstr "" -#: funnel/forms/notification.py:40 funnel/forms/sync_ticket.py:123 -#: funnel/templates/macros.html.jinja2:96 +#: funnel/forms/notification.py:40 funnel/forms/sync_ticket.py:133 +#: funnel/templates/macros.html.jinja2:86 +#: funnel/templates/ticket_event.html.jinja2:66 +#: funnel/templates/ticket_type.html.jinja2:27 msgid "Email" msgstr "" @@ -866,39 +880,75 @@ msgstr "" msgid "Disabled this WhatsApp notification" msgstr "" -#: funnel/forms/notification.py:127 +#: funnel/forms/notification.py:104 +msgid "Signal" +msgstr "" + +#: funnel/forms/notification.py:105 +msgid "To enable, add your Signal number" +msgstr "" + +#: funnel/forms/notification.py:107 +msgid "Notify me on Signal (beta)" +msgstr "" + +#: funnel/forms/notification.py:108 +msgid "Uncheck this to disable all Signal notifications" +msgstr "" + +#: funnel/forms/notification.py:109 +msgid "Signal notifications" +msgstr "" + +#: funnel/forms/notification.py:110 +msgid "Enabled selected Signal notifications" +msgstr "" + +#: funnel/forms/notification.py:111 +msgid "Enabled this Signal notification" +msgstr "" + +#: funnel/forms/notification.py:112 +msgid "Disabled all Signal notifications" +msgstr "" + +#: funnel/forms/notification.py:113 +msgid "Disabled this Signal notification" +msgstr "" + +#: funnel/forms/notification.py:139 msgid "Notify me" msgstr "" -#: funnel/forms/notification.py:127 +#: funnel/forms/notification.py:139 msgid "Uncheck this to disable all notifications" msgstr "" -#: funnel/forms/notification.py:131 +#: funnel/forms/notification.py:143 msgid "Or disable only a specific notification" msgstr "" -#: funnel/forms/notification.py:139 +#: funnel/forms/notification.py:151 msgid "Unsubscribe token" msgstr "" -#: funnel/forms/notification.py:142 +#: funnel/forms/notification.py:154 msgid "Unsubscribe token type" msgstr "" -#: funnel/forms/notification.py:201 +#: funnel/forms/notification.py:213 msgid "Notification type" msgstr "" -#: funnel/forms/notification.py:203 +#: funnel/forms/notification.py:215 msgid "Transport" msgstr "" -#: funnel/forms/notification.py:205 +#: funnel/forms/notification.py:217 msgid "Enable this transport" msgstr "" -#: funnel/forms/notification.py:210 +#: funnel/forms/notification.py:222 msgid "Main switch" msgstr "" @@ -912,47 +962,47 @@ msgstr "" #: funnel/forms/organization.py:38 msgid "" -"A short name for your organization’s account page. Single word containing" -" letters, numbers and dashes only. Pick something permanent: changing it " -"will break existing links from around the web" +"A unique word for your organization’s account page. Alphabets, numbers " +"and underscores are okay. Pick something permanent: changing it will " +"break links" msgstr "" -#: funnel/forms/organization.py:60 -msgid "Names can only have letters, numbers and dashes (except at the ends)" +#: funnel/forms/organization.py:59 +msgid "Names can only have alphabets, numbers and underscores" msgstr "" -#: funnel/forms/organization.py:66 +#: funnel/forms/organization.py:62 msgid "This name is reserved" msgstr "" -#: funnel/forms/organization.py:75 +#: funnel/forms/organization.py:71 msgid "" "This is your current username. You must change it first from your account before you can assign it to an " "organization" msgstr "" -#: funnel/forms/organization.py:83 +#: funnel/forms/organization.py:79 msgid "This name has been taken by another user" msgstr "" -#: funnel/forms/organization.py:87 +#: funnel/forms/organization.py:83 msgid "This name has been taken by another organization" msgstr "" -#: funnel/forms/organization.py:98 +#: funnel/forms/organization.py:94 msgid "Team name" msgstr "" -#: funnel/forms/organization.py:106 funnel/templates/auth_client.html.jinja2:66 +#: funnel/forms/organization.py:102 funnel/templates/auth_client.html.jinja2:66 msgid "Users" msgstr "" -#: funnel/forms/organization.py:111 +#: funnel/forms/organization.py:107 msgid "Make this team public" msgstr "" -#: funnel/forms/organization.py:112 +#: funnel/forms/organization.py:108 msgid "Team members will be listed on the organization’s account page" msgstr "" @@ -968,11 +1018,11 @@ msgstr "" msgid "Welcome message" msgstr "" -#: funnel/forms/profile.py:40 funnel/forms/profile.py:80 +#: funnel/forms/profile.py:40 funnel/forms/profile.py:78 msgid "Optional – This message will be shown on the account’s page" msgstr "" -#: funnel/forms/profile.py:43 funnel/forms/profile.py:112 +#: funnel/forms/profile.py:43 funnel/forms/profile.py:110 msgid "Account image" msgstr "" @@ -990,230 +1040,228 @@ msgstr "" #: funnel/forms/profile.py:73 msgid "" -"A short name for mentioning you with @username, and the URL to your " -"account’s page. Single word containing letters, numbers and dashes only. " -"Pick something permanent: changing it will break existing links from " -"around the web" +"A single word that is uniquely yours, for your account page and " +"@mentions. Pick something permanent: changing it will break existing " +"links" msgstr "" -#: funnel/forms/profile.py:79 +#: funnel/forms/profile.py:77 msgid "More about you" msgstr "" -#: funnel/forms/profile.py:92 +#: funnel/forms/profile.py:90 msgid "Account visibility" msgstr "" -#: funnel/forms/profile.py:139 funnel/forms/project.py:100 -#: funnel/forms/project.py:213 +#: funnel/forms/profile.py:137 funnel/forms/project.py:105 +#: funnel/forms/project.py:219 msgid "Banner image" msgstr "" -#: funnel/forms/project.py:45 funnel/forms/proposal.py:157 -#: funnel/forms/session.py:19 funnel/forms/sync_ticket.py:56 -#: funnel/forms/sync_ticket.py:95 funnel/forms/update.py:17 +#: funnel/forms/project.py:50 funnel/forms/proposal.py:158 +#: funnel/forms/session.py:19 funnel/forms/sync_ticket.py:66 +#: funnel/forms/sync_ticket.py:105 funnel/forms/update.py:17 #: funnel/templates/auth_client_index.html.jinja2:15 +#: funnel/templates/submission_form.html.jinja2:52 msgid "Title" msgstr "" -#: funnel/forms/project.py:50 +#: funnel/forms/project.py:55 msgid "Tagline" msgstr "" -#: funnel/forms/project.py:53 +#: funnel/forms/project.py:58 msgid "One line description of the project" msgstr "" -#: funnel/forms/project.py:56 funnel/forms/venue.py:67 -#: funnel/templates/macros.html.jinja2:794 +#: funnel/forms/project.py:61 funnel/forms/venue.py:67 +#: funnel/templates/macros.html.jinja2:642 #: funnel/templates/past_projects_section.html.jinja2:12 msgid "Location" msgstr "" -#: funnel/forms/project.py:57 +#: funnel/forms/project.py:62 msgid "“Online” if this is online-only, else the city or region (without quotes)" msgstr "" -#: funnel/forms/project.py:62 +#: funnel/forms/project.py:67 msgid "If this project is online-only, use “Online”" msgstr "" -#: funnel/forms/project.py:65 +#: funnel/forms/project.py:70 #, python-format msgid "%(max)d characters maximum" msgstr "" -#: funnel/forms/project.py:71 +#: funnel/forms/project.py:76 msgid "Optional – Starting time" msgstr "" -#: funnel/forms/project.py:76 +#: funnel/forms/project.py:81 msgid "Optional – Ending time" msgstr "" -#: funnel/forms/project.py:80 +#: funnel/forms/project.py:85 msgid "This is required when starting time is specified" msgstr "" -#: funnel/forms/project.py:83 +#: funnel/forms/project.py:88 msgid "This requires a starting time too" msgstr "" -#: funnel/forms/project.py:87 +#: funnel/forms/project.py:92 msgid "This must be after the starting time" msgstr "" -#: funnel/forms/project.py:94 +#: funnel/forms/project.py:99 msgid "The timezone in which this event occurs" msgstr "" -#: funnel/forms/project.py:109 +#: funnel/forms/project.py:114 msgid "Project description" msgstr "" -#: funnel/forms/project.py:111 +#: funnel/forms/project.py:116 msgid "Landing page contents" msgstr "" -#: funnel/forms/project.py:118 +#: funnel/forms/project.py:123 msgid "Quotes are not necessary in the location name" msgstr "" -#: funnel/forms/project.py:135 funnel/templates/project_layout.html.jinja2:247 +#: funnel/forms/project.py:140 funnel/templates/project_layout.html.jinja2:213 msgid "Feature this project" msgstr "" -#: funnel/forms/project.py:143 +#: funnel/forms/project.py:148 msgid "" "Livestream URLs. One per line. Must be on YouTube or Vimeo. Must begin " "with https://" msgstr "" -#: funnel/forms/project.py:164 +#: funnel/forms/project.py:169 msgid "Livestream must be on YouTube or Vimeo" msgstr "" -#: funnel/forms/project.py:179 funnel/templates/project_settings.html.jinja2:48 +#: funnel/forms/project.py:185 funnel/templates/project_settings.html.jinja2:48 msgid "Custom URL" msgstr "" -#: funnel/forms/project.py:180 +#: funnel/forms/project.py:186 msgid "" "Customize the URL of your project. Use lowercase letters, numbers and " "dashes only. Including a date is recommended" msgstr "" -#: funnel/forms/project.py:189 +#: funnel/forms/project.py:195 msgid "" "This URL contains unsupported characters. It can contain lowercase " "letters, numbers and hyphens only" msgstr "" -#: funnel/forms/project.py:233 +#: funnel/forms/project.py:239 msgid "Guidelines" msgstr "" -#: funnel/forms/project.py:236 +#: funnel/forms/project.py:242 msgid "" "Set guidelines for the type of submissions your project is accepting, " "your review process, and anything else relevant to the submission" msgstr "" -#: funnel/forms/project.py:242 +#: funnel/forms/project.py:248 msgid "Submissions close at" msgstr "" -#: funnel/forms/project.py:243 +#: funnel/forms/project.py:249 msgid "Optional – Leave blank to have no closing date" msgstr "" -#: funnel/forms/project.py:252 +#: funnel/forms/project.py:258 msgid "Closing date must be in the future" msgstr "" -#: funnel/forms/project.py:263 funnel/forms/project.py:332 -#: funnel/forms/proposal.py:228 +#: funnel/forms/project.py:269 funnel/forms/project.py:338 +#: funnel/forms/proposal.py:229 msgid "Status" msgstr "" -#: funnel/forms/project.py:276 +#: funnel/forms/project.py:282 msgid "Open submissions" msgstr "" -#: funnel/forms/project.py:299 funnel/templates/layout.html.jinja2:115 -#: funnel/templates/macros.html.jinja2:142 +#: funnel/forms/project.py:305 funnel/templates/layout.html.jinja2:178 +#: funnel/templates/macros.html.jinja2:132 msgid "Account" msgstr "" -#: funnel/forms/project.py:302 +#: funnel/forms/project.py:308 msgid "Choose a sponsor" msgstr "" -#: funnel/forms/project.py:307 +#: funnel/forms/project.py:313 msgid "Optional – Label for sponsor" msgstr "" -#: funnel/forms/project.py:310 +#: funnel/forms/project.py:316 msgid "Mark this sponsor as promoted" msgstr "" -#: funnel/forms/project.py:318 +#: funnel/forms/project.py:324 msgid "Save this project?" msgstr "" -#: funnel/forms/project.py:321 funnel/forms/session.py:75 +#: funnel/forms/project.py:327 funnel/forms/session.py:75 msgid "Note to self" msgstr "" -#: funnel/forms/proposal.py:51 funnel/forms/proposal.py:98 +#: funnel/forms/proposal.py:51 funnel/forms/proposal.py:99 msgid "Please select one" msgstr "" -#: funnel/forms/proposal.py:118 funnel/templates/submission.html.jinja2:70 +#: funnel/forms/proposal.py:119 funnel/templates/submission.html.jinja2:150 msgid "Feature this submission" msgstr "" -#: funnel/forms/proposal.py:127 funnel/forms/proposal.py:141 -#: funnel/forms/proposal.py:175 funnel/templates/labels.html.jinja2:5 +#: funnel/forms/proposal.py:128 funnel/forms/proposal.py:142 +#: funnel/forms/proposal.py:176 funnel/templates/labels.html.jinja2:6 #: funnel/templates/project_settings.html.jinja2:63 -#: funnel/templates/submission_form.html.jinja2:62 +#: funnel/templates/submission_admin_panel.html.jinja2:29 +#: funnel/templates/submission_form.html.jinja2:58 msgid "Labels" msgstr "" -#: funnel/forms/proposal.py:162 funnel/forms/update.py:22 +#: funnel/forms/proposal.py:163 funnel/forms/update.py:22 #: funnel/templates/siteadmin_comments.html.jinja2:53 +#: funnel/templates/submission_form.html.jinja2:110 msgid "Content" msgstr "" -#: funnel/forms/proposal.py:165 funnel/templates/submission_form.html.jinja2:72 +#: funnel/forms/proposal.py:166 funnel/templates/submission_form.html.jinja2:73 msgid "Video" msgstr "" -#: funnel/forms/proposal.py:173 +#: funnel/forms/proposal.py:174 msgid "YouTube or Vimeo URL (optional)" msgstr "" -#: funnel/forms/proposal.py:202 funnel/templates/js/membership.js.jinja2:24 -msgid "Role" -msgstr "" - -#: funnel/forms/proposal.py:203 +#: funnel/forms/proposal.py:204 msgid "Optional – A specific role in this submission (like Author or Editor)" msgstr "" -#: funnel/forms/proposal.py:208 +#: funnel/forms/proposal.py:209 msgid "Hide collaborator on submission" msgstr "" -#: funnel/forms/proposal.py:215 +#: funnel/forms/proposal.py:216 msgid "{user} is already a collaborator" msgstr "" -#: funnel/forms/proposal.py:247 +#: funnel/forms/proposal.py:248 msgid "Move proposal to" msgstr "" -#: funnel/forms/proposal.py:248 +#: funnel/forms/proposal.py:249 msgid "Move this proposal to another project" msgstr "" @@ -1273,60 +1321,84 @@ msgstr "" msgid "If checked, both free and buy tickets will shown on project" msgstr "" -#: funnel/forms/sync_ticket.py:61 +#: funnel/forms/sync_ticket.py:50 +msgid "This is a subscription" +msgstr "" + +#: funnel/forms/sync_ticket.py:52 +msgid "If not checked, buy tickets button will be shown" +msgstr "" + +#: funnel/forms/sync_ticket.py:55 +msgid "Register button text" +msgstr "" + +#: funnel/forms/sync_ticket.py:57 +msgid "Optional – Use with care to replace the button text" +msgstr "" + +#: funnel/forms/sync_ticket.py:71 msgid "Badge template URL" msgstr "" -#: funnel/forms/sync_ticket.py:72 funnel/forms/venue.py:27 +#: funnel/forms/sync_ticket.py:72 +msgid "URL of background image for the badge" +msgstr "" + +#: funnel/forms/sync_ticket.py:82 funnel/forms/venue.py:27 #: funnel/forms/venue.py:90 funnel/templates/js/membership.js.jinja2:23 +#: funnel/templates/project_rsvp_list.html.jinja2:11 +#: funnel/templates/ticket_event.html.jinja2:64 +#: funnel/templates/ticket_type.html.jinja2:26 msgid "Name" msgstr "" -#: funnel/forms/sync_ticket.py:77 +#: funnel/forms/sync_ticket.py:87 msgid "Client id" msgstr "" -#: funnel/forms/sync_ticket.py:80 +#: funnel/forms/sync_ticket.py:90 msgid "Client event id" msgstr "" -#: funnel/forms/sync_ticket.py:83 +#: funnel/forms/sync_ticket.py:93 msgid "Client event secret" msgstr "" -#: funnel/forms/sync_ticket.py:86 +#: funnel/forms/sync_ticket.py:96 msgid "Client access token" msgstr "" -#: funnel/forms/sync_ticket.py:100 funnel/forms/sync_ticket.py:154 +#: funnel/forms/sync_ticket.py:110 funnel/forms/sync_ticket.py:164 #: funnel/templates/project_admin.html.jinja2:17 #: funnel/templates/project_settings.html.jinja2:88 #: funnel/templates/ticket_event_list.html.jinja2:15 msgid "Events" msgstr "" -#: funnel/forms/sync_ticket.py:118 +#: funnel/forms/sync_ticket.py:128 msgid "Fullname" msgstr "" -#: funnel/forms/sync_ticket.py:133 funnel/forms/venue.py:46 +#: funnel/forms/sync_ticket.py:143 funnel/forms/venue.py:46 msgid "City" msgstr "" -#: funnel/forms/sync_ticket.py:138 +#: funnel/forms/sync_ticket.py:148 funnel/templates/ticket_event.html.jinja2:67 +#: funnel/templates/ticket_type.html.jinja2:28 msgid "Company" msgstr "" -#: funnel/forms/sync_ticket.py:143 +#: funnel/forms/sync_ticket.py:153 msgid "Job title" msgstr "" -#: funnel/forms/sync_ticket.py:148 funnel/loginproviders/init_app.py:31 -#: funnel/templates/macros.html.jinja2:97 +#: funnel/forms/sync_ticket.py:158 funnel/loginproviders/init_app.py:31 +#: funnel/templates/macros.html.jinja2:87 msgid "Twitter" msgstr "" -#: funnel/forms/sync_ticket.py:152 +#: funnel/forms/sync_ticket.py:162 msgid "Badge is printed" msgstr "" @@ -1404,7 +1476,7 @@ msgstr "" #: funnel/loginproviders/github.py:45 funnel/loginproviders/linkedin.py:61 #: funnel/loginproviders/zoom.py:49 -msgid "This server's callback URL is misconfigured" +msgid "This server’s callback URL is misconfigured" msgstr "" #: funnel/loginproviders/github.py:47 funnel/loginproviders/google.py:42 @@ -1436,7 +1508,7 @@ msgstr "" msgid "Google" msgstr "" -#: funnel/loginproviders/init_app.py:43 funnel/templates/macros.html.jinja2:99 +#: funnel/loginproviders/init_app.py:43 funnel/templates/macros.html.jinja2:89 msgid "LinkedIn" msgstr "" @@ -1485,7 +1557,7 @@ msgstr "" msgid "Zoom had an intermittent problem. Try again?" msgstr "" -#: funnel/models/auth_client.py:560 +#: funnel/models/auth_client.py:546 msgid "Unrecognized algorithm ‘{value}’" msgstr "" @@ -1493,7 +1565,7 @@ msgstr "" msgid "Disabled" msgstr "" -#: funnel/models/comment.py:37 funnel/models/project.py:409 +#: funnel/models/comment.py:37 funnel/models/project.py:405 msgid "Open" msgstr "" @@ -1505,7 +1577,7 @@ msgstr "" msgid "Collaborators-only" msgstr "" -#: funnel/models/comment.py:46 funnel/models/proposal.py:47 +#: funnel/models/comment.py:46 funnel/models/proposal.py:45 msgid "Submitted" msgstr "" @@ -1518,13 +1590,13 @@ msgstr "" msgid "Hidden" msgstr "" -#: funnel/models/comment.py:49 funnel/models/moderation.py:17 +#: funnel/models/comment.py:49 funnel/models/moderation.py:19 msgid "Spam" msgstr "" -#: funnel/models/comment.py:51 funnel/models/project.py:55 -#: funnel/models/proposal.py:54 funnel/models/update.py:41 -#: funnel/models/user.py:127 +#: funnel/models/comment.py:51 funnel/models/project.py:54 +#: funnel/models/proposal.py:52 funnel/models/update.py:41 +#: funnel/models/user.py:128 msgid "Deleted" msgstr "" @@ -1532,390 +1604,383 @@ msgstr "" msgid "Verified" msgstr "" -#: funnel/models/comment.py:69 funnel/models/user.py:1027 +#: funnel/models/comment.py:69 funnel/models/user.py:1053 msgid "[deleted]" msgstr "" -#: funnel/models/comment.py:70 funnel/models/user.py:1028 +#: funnel/models/comment.py:70 funnel/models/phone_number.py:420 +#: funnel/models/user.py:1054 msgid "[removed]" msgstr "" -#: funnel/models/comment.py:342 +#: funnel/models/comment.py:349 msgid "{user} commented on {obj}" msgstr "" -#: funnel/models/comment.py:345 +#: funnel/models/comment.py:352 msgid "{user} commented" msgstr "" -#: funnel/models/comment.py:358 +#: funnel/models/comment.py:365 msgid "Submitter" msgstr "" -#: funnel/models/comment.py:361 +#: funnel/models/comment.py:368 msgid "Editor & Promoter" msgstr "" -#: funnel/models/membership_mixin.py:63 +#: funnel/models/membership_mixin.py:65 msgid "Invite" msgstr "" -#: funnel/models/membership_mixin.py:65 +#: funnel/models/membership_mixin.py:69 msgid "Direct add" msgstr "" -#: funnel/models/membership_mixin.py:66 +#: funnel/models/membership_mixin.py:71 msgid "Amend" msgstr "" -#: funnel/models/moderation.py:16 +#: funnel/models/moderation.py:18 msgid "Not spam" msgstr "" -#: funnel/models/notification.py:148 +#: funnel/models/notification.py:162 msgid "Uncategorized" msgstr "" -#: funnel/models/notification.py:149 funnel/templates/account.html.jinja2:5 +#: funnel/models/notification.py:163 funnel/templates/account.html.jinja2:5 #: funnel/templates/account_saved.html.jinja2:4 #: funnel/templates/js/badge.js.jinja2:96 #: funnel/templates/notification_preferences.html.jinja2:5 msgid "My account" msgstr "" -#: funnel/models/notification.py:151 +#: funnel/models/notification.py:165 msgid "My subscriptions and billing" msgstr "" -#: funnel/models/notification.py:155 +#: funnel/models/notification.py:169 msgid "Projects I am participating in" msgstr "" -#: funnel/models/notification.py:166 +#: funnel/models/notification.py:180 msgid "Projects I am a crew member in" msgstr "" -#: funnel/models/notification.py:174 -msgid "Organizations I manage" +#: funnel/models/notification.py:188 +msgid "Accounts I manage" msgstr "" -#: funnel/models/notification.py:182 +#: funnel/models/notification.py:196 msgid "As a website administrator" msgstr "" -#: funnel/models/notification.py:195 +#: funnel/models/notification.py:209 msgid "Queued" msgstr "" -#: funnel/models/notification.py:196 +#: funnel/models/notification.py:210 msgid "Pending" msgstr "" -#: funnel/models/notification.py:197 +#: funnel/models/notification.py:211 msgid "Delivered" msgstr "" -#: funnel/models/notification.py:198 +#: funnel/models/notification.py:212 msgid "Failed" msgstr "" -#: funnel/models/notification.py:199 +#: funnel/models/notification.py:213 #: funnel/templates/auth_client.html.jinja2:92 msgid "Unknown" msgstr "" -#: funnel/models/notification.py:260 +#: funnel/models/notification.py:310 msgid "Unspecified notification type" msgstr "" -#: funnel/models/notification_types.py:81 +#: funnel/models/notification_types.py:83 msgid "When my account password changes" msgstr "" -#: funnel/models/notification_types.py:82 +#: funnel/models/notification_types.py:84 msgid "For your safety, in case this was not authorized" msgstr "" -#: funnel/models/notification_types.py:98 +#: funnel/models/notification_types.py:101 msgid "When I register for a project" msgstr "" -#: funnel/models/notification_types.py:99 +#: funnel/models/notification_types.py:102 msgid "This will prompt a calendar entry in Gmail and other apps" msgstr "" -#: funnel/models/notification_types.py:112 -msgid "When I cancel my registration" -msgstr "" - -#: funnel/models/notification_types.py:113 -#: funnel/models/notification_types.py:145 -msgid "Confirmation for your records" -msgstr "" - -#: funnel/models/notification_types.py:128 +#: funnel/models/notification_types.py:129 msgid "When a project posts an update" msgstr "" -#: funnel/models/notification_types.py:129 +#: funnel/models/notification_types.py:130 msgid "Typically contains critical information such as video conference links" msgstr "" -#: funnel/models/notification_types.py:144 +#: funnel/models/notification_types.py:145 msgid "When I submit a proposal" msgstr "" -#: funnel/models/notification_types.py:165 -msgid "When a project I’ve registered for is about to start" +#: funnel/models/notification_types.py:146 +msgid "Confirmation for your records" msgstr "" #: funnel/models/notification_types.py:166 -msgid "You will be notified 5-10 minutes before the starting time" +msgid "When a project I’ve registered for is about to start" msgstr "" -#: funnel/models/notification_types.py:183 -msgid "When there is a new comment on a project or proposal I’m in" +#: funnel/models/notification_types.py:167 +msgid "You will be notified 5-10 minutes before the starting time" msgstr "" -#: funnel/models/notification_types.py:197 -msgid "When someone replies to my comment" +#: funnel/models/notification_types.py:182 +msgid "When there is a new comment on something I’m involved in" msgstr "" -#: funnel/models/notification_types.py:215 -msgid "When a project crew member is added, or roles change" +#: funnel/models/notification_types.py:194 +msgid "When someone replies to my comment or mentions me" msgstr "" -#: funnel/models/notification_types.py:216 -msgid "Crew members have access to the project’s controls" +#: funnel/models/notification_types.py:211 +msgid "When a project crew member is added or removed" msgstr "" -#: funnel/models/notification_types.py:231 -msgid "When a project crew member is removed, including me" +#: funnel/models/notification_types.py:212 +msgid "Crew members have access to the project’s settings and data" msgstr "" -#: funnel/models/notification_types.py:245 +#: funnel/models/notification_types.py:240 msgid "When my project receives a new proposal" msgstr "" -#: funnel/models/notification_types.py:260 +#: funnel/models/notification_types.py:256 msgid "When someone registers for my project" msgstr "" -#: funnel/models/notification_types.py:277 -msgid "When organization admins change" +#: funnel/models/notification_types.py:273 +msgid "When account admins change" msgstr "" -#: funnel/models/notification_types.py:278 -msgid "Organization admins control all projects under the organization" +#: funnel/models/notification_types.py:274 +msgid "Account admins control all projects under the account" msgstr "" -#: funnel/models/notification_types.py:292 -msgid "When an organization admin is removed, including me" +#: funnel/models/notification_types.py:303 +msgid "When a comment is reported as spam" msgstr "" -#: funnel/models/notification_types.py:309 -msgid "When a comment is reported as spam" +#: funnel/models/phone_number.py:419 +msgid "[blocked]" msgstr "" -#: funnel/models/profile.py:43 +#: funnel/models/profile.py:47 msgid "Autogenerated" msgstr "" -#: funnel/models/profile.py:44 funnel/models/project.py:62 +#: funnel/models/profile.py:48 funnel/models/project.py:61 #: funnel/models/update.py:45 funnel/templates/auth_client.html.jinja2:44 msgid "Public" msgstr "" -#: funnel/models/profile.py:45 +#: funnel/models/profile.py:49 #: funnel/templates/organization_teams.html.jinja2:19 msgid "Private" msgstr "" -#: funnel/models/profile.py:462 funnel/templates/macros.html.jinja2:490 +#: funnel/models/profile.py:485 funnel/templates/profile_layout.html.jinja2:90 msgid "Make public" msgstr "" -#: funnel/models/profile.py:473 funnel/templates/macros.html.jinja2:427 +#: funnel/models/profile.py:496 funnel/templates/profile_layout.html.jinja2:27 msgid "Make private" msgstr "" -#: funnel/models/project.py:52 funnel/models/project.py:400 -#: funnel/models/proposal.py:46 funnel/models/proposal.py:294 +#: funnel/models/project.py:51 funnel/models/project.py:396 +#: funnel/models/proposal.py:44 funnel/models/proposal.py:297 #: funnel/models/update.py:39 funnel/templates/js/update.js.jinja2:5 #: funnel/templates/js/update.js.jinja2:30 msgid "Draft" msgstr "" -#: funnel/models/project.py:53 funnel/models/update.py:40 +#: funnel/models/project.py:52 funnel/models/update.py:40 msgid "Published" msgstr "" -#: funnel/models/project.py:54 funnel/models/update.py:268 +#: funnel/models/project.py:53 funnel/models/update.py:269 msgid "Withdrawn" msgstr "" -#: funnel/models/project.py:61 +#: funnel/models/project.py:60 msgid "None" msgstr "" -#: funnel/models/project.py:63 +#: funnel/models/project.py:62 msgid "Closed" msgstr "" -#: funnel/models/project.py:347 +#: funnel/models/project.py:343 msgid "Past" msgstr "" -#: funnel/models/project.py:360 +#: funnel/models/project.py:356 funnel/templates/macros.html.jinja2:240 msgid "Live" msgstr "" -#: funnel/models/project.py:367 funnel/templates/macros.html.jinja2:336 +#: funnel/models/project.py:363 funnel/templates/macros.html.jinja2:326 msgid "Upcoming" msgstr "" -#: funnel/models/project.py:374 +#: funnel/models/project.py:370 msgid "Published without sessions" msgstr "" -#: funnel/models/project.py:383 +#: funnel/models/project.py:379 msgid "Has submissions" msgstr "" -#: funnel/models/project.py:391 +#: funnel/models/project.py:387 msgid "Has sessions" msgstr "" -#: funnel/models/project.py:419 +#: funnel/models/project.py:415 msgid "Expired" msgstr "" -#: funnel/models/project.py:455 +#: funnel/models/project.py:451 msgid "Enable submissions" msgstr "" -#: funnel/models/project.py:456 +#: funnel/models/project.py:452 msgid "Submissions will be accepted until the optional closing date" msgstr "" -#: funnel/models/project.py:471 +#: funnel/models/project.py:468 msgid "Disable submissions" msgstr "" -#: funnel/models/project.py:472 +#: funnel/models/project.py:469 msgid "Submissions will no longer be accepted" msgstr "" -#: funnel/models/project.py:482 +#: funnel/models/project.py:479 msgid "Publish project" msgstr "" -#: funnel/models/project.py:483 +#: funnel/models/project.py:480 msgid "The project has been published" msgstr "" -#: funnel/models/project.py:499 +#: funnel/models/project.py:496 msgid "Withdraw project" msgstr "" -#: funnel/models/project.py:500 +#: funnel/models/project.py:497 msgid "The project has been withdrawn and is no longer listed" msgstr "" -#: funnel/models/proposal.py:48 +#: funnel/models/proposal.py:46 msgid "Confirmed" msgstr "" -#: funnel/models/proposal.py:49 +#: funnel/models/proposal.py:47 msgid "Waitlisted" msgstr "" -#: funnel/models/proposal.py:50 +#: funnel/models/proposal.py:48 msgid "Rejected" msgstr "" -#: funnel/models/proposal.py:51 +#: funnel/models/proposal.py:49 msgid "Cancelled" msgstr "" -#: funnel/models/proposal.py:52 funnel/models/proposal.py:395 +#: funnel/models/proposal.py:50 funnel/models/proposal.py:398 msgid "Awaiting details" msgstr "" -#: funnel/models/proposal.py:53 funnel/models/proposal.py:406 +#: funnel/models/proposal.py:51 funnel/models/proposal.py:409 msgid "Under evaluation" msgstr "" -#: funnel/models/proposal.py:57 +#: funnel/models/proposal.py:55 msgid "Shortlisted" msgstr "" -#: funnel/models/proposal.py:61 +#: funnel/models/proposal.py:59 msgid "Shortlisted for rehearsal" msgstr "" -#: funnel/models/proposal.py:63 +#: funnel/models/proposal.py:61 msgid "Rehearsal ongoing" msgstr "" -#: funnel/models/proposal.py:287 +#: funnel/models/proposal.py:290 msgid "Confirmed & scheduled" msgstr "" -#: funnel/models/proposal.py:295 +#: funnel/models/proposal.py:298 msgid "This proposal has been withdrawn" msgstr "" -#: funnel/models/proposal.py:305 funnel/templates/forms.html.jinja2:183 -#: funnel/templates/submission_form.html.jinja2:45 -#: funnel/templates/submission_form.html.jinja2:48 +#: funnel/models/proposal.py:308 funnel/templates/forms.html.jinja2:190 +#: funnel/templates/project_cfp.html.jinja2:52 +#: funnel/templates/submission_form.html.jinja2:40 +#: funnel/templates/submission_form.html.jinja2:42 msgid "Submit" msgstr "" -#: funnel/models/proposal.py:306 funnel/models/proposal.py:319 +#: funnel/models/proposal.py:309 funnel/models/proposal.py:322 msgid "This proposal has been submitted" msgstr "" -#: funnel/models/proposal.py:318 +#: funnel/models/proposal.py:321 msgid "Send Back to Submitted" msgstr "" -#: funnel/models/proposal.py:329 -#: funnel/templates/project_layout.html.jinja2:150 -#: funnel/views/account_reset.py:176 funnel/views/comment.py:445 -#: funnel/views/login.py:110 funnel/views/login_session.py:689 +#: funnel/models/proposal.py:332 +#: funnel/templates/project_layout.html.jinja2:145 +#: funnel/views/account_reset.py:178 funnel/views/comment.py:445 +#: funnel/views/login.py:110 funnel/views/login_session.py:693 msgid "Confirm" msgstr "" -#: funnel/models/proposal.py:330 +#: funnel/models/proposal.py:333 msgid "This proposal has been confirmed" msgstr "" -#: funnel/models/proposal.py:340 +#: funnel/models/proposal.py:343 msgid "Unconfirm" msgstr "" -#: funnel/models/proposal.py:341 +#: funnel/models/proposal.py:344 msgid "This proposal is no longer confirmed" msgstr "" -#: funnel/models/proposal.py:351 +#: funnel/models/proposal.py:354 msgid "Waitlist" msgstr "" -#: funnel/models/proposal.py:352 +#: funnel/models/proposal.py:355 msgid "This proposal has been waitlisted" msgstr "" -#: funnel/models/proposal.py:362 +#: funnel/models/proposal.py:365 msgid "Reject" msgstr "" -#: funnel/models/proposal.py:363 +#: funnel/models/proposal.py:366 msgid "This proposal has been rejected" msgstr "" -#: funnel/models/proposal.py:373 funnel/templates/delete.html.jinja2:12 +#: funnel/models/proposal.py:376 funnel/templates/delete.html.jinja2:12 #: funnel/templates/forms.html.jinja2:150 #: funnel/templates/js/membership.js.jinja2:76 #: funnel/templates/otpform.html.jinja2:10 @@ -1923,50 +1988,52 @@ msgstr "" msgid "Cancel" msgstr "" -#: funnel/models/proposal.py:374 +#: funnel/models/proposal.py:377 msgid "This proposal has been cancelled" msgstr "" -#: funnel/models/proposal.py:384 +#: funnel/models/proposal.py:387 msgid "Undo cancel" msgstr "" -#: funnel/models/proposal.py:385 -msgid "This proposal's cancellation has been reversed" +#: funnel/models/proposal.py:388 +msgid "This proposal’s cancellation has been reversed" msgstr "" -#: funnel/models/proposal.py:396 +#: funnel/models/proposal.py:399 msgid "Awaiting details for this proposal" msgstr "" -#: funnel/models/proposal.py:407 +#: funnel/models/proposal.py:410 msgid "This proposal has been put under evaluation" msgstr "" -#: funnel/models/proposal.py:417 funnel/templates/auth_client.html.jinja2:17 +#: funnel/models/proposal.py:420 funnel/templates/auth_client.html.jinja2:17 #: funnel/templates/auth_client.html.jinja2:170 #: funnel/templates/delete.html.jinja2:11 #: funnel/templates/js/comments.js.jinja2:89 -#: funnel/templates/labels.html.jinja2:52 +#: funnel/templates/labels.html.jinja2:85 #: funnel/templates/organization_teams.html.jinja2:42 -#: funnel/templates/submission.html.jinja2:62 funnel/views/comment.py:400 -#: funnel/views/label.py:258 funnel/views/update.py:186 +#: funnel/templates/submission.html.jinja2:134 +#: funnel/templates/venues.html.jinja2:26 +#: funnel/templates/venues.html.jinja2:58 funnel/views/comment.py:400 +#: funnel/views/label.py:259 funnel/views/update.py:186 msgid "Delete" msgstr "" -#: funnel/models/proposal.py:418 +#: funnel/models/proposal.py:421 msgid "This proposal has been deleted" msgstr "" -#: funnel/models/rsvp.py:29 funnel/models/rsvp.py:94 +#: funnel/models/rsvp.py:29 funnel/models/rsvp.py:95 msgid "Going" msgstr "" -#: funnel/models/rsvp.py:30 funnel/models/rsvp.py:105 +#: funnel/models/rsvp.py:30 funnel/models/rsvp.py:106 msgid "Not going" msgstr "" -#: funnel/models/rsvp.py:31 funnel/models/rsvp.py:116 +#: funnel/models/rsvp.py:31 funnel/models/rsvp.py:117 msgid "Maybe" msgstr "" @@ -1974,7 +2041,7 @@ msgstr "" msgid "Awaiting" msgstr "" -#: funnel/models/rsvp.py:95 funnel/models/rsvp.py:106 funnel/models/rsvp.py:117 +#: funnel/models/rsvp.py:96 funnel/models/rsvp.py:107 funnel/models/rsvp.py:118 msgid "Your response has been saved" msgstr "" @@ -1982,39 +2049,58 @@ msgstr "" msgid "Restricted" msgstr "" -#: funnel/models/update.py:260 +#: funnel/models/update.py:261 msgid "Unpublished" msgstr "" -#: funnel/models/user.py:119 funnel/models/user.py:134 +#: funnel/models/user.py:120 funnel/models/user.py:135 msgid "Active" msgstr "" -#: funnel/models/user.py:121 funnel/models/user.py:136 +#: funnel/models/user.py:122 funnel/models/user.py:137 msgid "Suspended" msgstr "" -#: funnel/models/user.py:123 +#: funnel/models/user.py:124 msgid "Merged" msgstr "" -#: funnel/models/user.py:125 +#: funnel/models/user.py:126 msgid "Invited" msgstr "" -#: funnel/models/video_mixin.py:78 funnel/models/video_mixin.py:89 -msgid "This must be a shareable URL for a single file in Google Drive" +#: funnel/static/js/fullcalendar.packed.js:13965 +#: funnel/static/js/fullcalendar.packed.js:14063 +#: funnel/static/js/fullcalendar.packed.js:14149 +msgid "timeFormat" +msgstr "" + +#: funnel/static/js/fullcalendar.packed.js:13997 +#: funnel/static/js/fullcalendar.packed.js:14085 +msgid "dragOpacity" +msgstr "" + +#: funnel/static/js/fullcalendar.packed.js:13998 +#: funnel/static/js/fullcalendar.packed.js:14086 +msgid "dragRevertDuration" +msgstr "" + +#: funnel/static/js/fullcalendar.packed.js:14020 +msgid "defaultEventMinutes" msgstr "" -#: funnel/static/js/ractive.packed.js:11 +#: funnel/static/js/ractive.packed.js:2257 msgid "${}" msgstr "" -#: funnel/static/js/ractive.packed.js:12 +#: funnel/static/js/ractive.packed.js:5010 msgid "." msgstr "" -#: funnel/static/js/ractive.packed.js:13 +#: funnel/static/js/ractive.packed.js:6767 +#: funnel/static/js/ractive.packed.js:6774 +#: funnel/static/js/ractive.packed.js:6788 +#: funnel/static/js/ractive.packed.js:6809 msgid "@" msgstr "" @@ -2022,76 +2108,79 @@ msgstr "" msgid "The room sequence and colours have been updated" msgstr "" -#: funnel/static/js/schedules.js:124 funnel/static/js/schedules.js:215 -#: funnel/static/js/schedules.js:252 funnel/static/js/schedules.js:462 +#: funnel/static/js/schedules.js:124 funnel/static/js/schedules.js:224 +#: funnel/static/js/schedules.js:267 funnel/static/js/schedules.js:477 #: funnel/static/js/schedules.packed.js:1 msgid "The server could not be reached. Check connection and try again" msgstr "" -#: funnel/static/js/schedules.js:234 funnel/static/js/schedules.packed.js:1 -#: funnel/templates/submission.html.jinja2:82 funnel/views/session.py:70 -#: funnel/views/session.py:119 +#: funnel/static/js/schedules.js:249 funnel/static/js/schedules.packed.js:1 +#: funnel/templates/session_view_popup.html.jinja2:52 +#: funnel/templates/submission.html.jinja2:176 funnel/views/session.py:45 msgid "Edit session" msgstr "" -#: funnel/static/js/schedules.js:235 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:250 funnel/static/js/schedules.packed.js:1 msgid "Schedule session" msgstr "" -#: funnel/static/js/schedules.js:410 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:425 funnel/static/js/schedules.packed.js:1 msgid "Add new session" msgstr "" -#: funnel/static/js/schedules.js:445 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:460 funnel/static/js/schedules.packed.js:1 #, python-format msgid "Remove %s from the schedule?" msgstr "" -#: funnel/static/js/schedules.js:500 funnel/static/js/schedules.js:669 -#: funnel/static/js/schedules.js:689 funnel/static/js/schedules.packed.js:1 -#: funnel/templates/schedule_edit.html.jinja2:90 -#: funnel/views/organization.py:189 funnel/views/project.py:329 +#: funnel/static/js/schedules.js:515 funnel/static/js/schedules.js:684 +#: funnel/static/js/schedules.js:704 funnel/static/js/schedules.packed.js:1 +#: funnel/templates/schedule_edit.html.jinja2:97 +#: funnel/templates/submission_admin_panel.html.jinja2:39 +#: funnel/templates/submission_form.html.jinja2:40 +#: funnel/templates/submission_form.html.jinja2:42 +#: funnel/views/organization.py:189 funnel/views/project.py:343 #: funnel/views/update.py:158 funnel/views/venue.py:121 #: funnel/views/venue.py:184 msgid "Save" msgstr "" -#: funnel/static/js/schedules.js:541 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:556 funnel/static/js/schedules.packed.js:1 msgid "5 mins" msgstr "" -#: funnel/static/js/schedules.js:543 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:558 funnel/static/js/schedules.packed.js:1 msgid "15 mins" msgstr "" -#: funnel/static/js/schedules.js:545 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:560 funnel/static/js/schedules.packed.js:1 msgid "30 mins" msgstr "" -#: funnel/static/js/schedules.js:547 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:562 funnel/static/js/schedules.packed.js:1 msgid "60 mins" msgstr "" -#: funnel/static/js/schedules.js:571 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:586 funnel/static/js/schedules.packed.js:1 msgid "Autosave" msgstr "" -#: funnel/static/js/schedules.js:673 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:688 funnel/static/js/schedules.packed.js:1 msgid "Saving…" msgstr "" -#: funnel/static/js/schedules.js:685 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:700 funnel/static/js/schedules.packed.js:1 msgid "Saved" msgstr "" -#: funnel/static/js/schedules.js:692 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:707 funnel/static/js/schedules.packed.js:1 #, python-format msgid "" "The server could not be reached. There are %d unsaved sessions. Check " "connection and try again" msgstr "" -#: funnel/static/js/schedules.js:699 funnel/static/js/schedules.packed.js:1 +#: funnel/static/js/schedules.js:714 funnel/static/js/schedules.packed.js:1 msgid "" "The server could not be reached. There is 1 unsaved session. Check " "connection and try again" @@ -2099,17 +2188,16 @@ msgstr "" #: funnel/templates/about.html.jinja2:2 funnel/templates/about.html.jinja2:13 #: funnel/templates/about.html.jinja2:18 -#: funnel/templates/macros.html.jinja2:552 +#: funnel/templates/macros.html.jinja2:397 msgid "About Hasgeek" msgstr "" #: funnel/templates/about.html.jinja2:29 msgid "" -"It’s 2022, and the world as we know it is slightly upturned. Meeting new " -"people and geeking-out about your passion has become harder than it used " -"to be. These special interactions that drive us to do new things and " -"explore new ideas also need a new place. It’s time to rebuild everything." -" Join us." +"In the post-pandemic world, meeting new people and geeking-out about your" +" passion has become harder than it used to be. These special interactions" +" that drive us to do new things and explore new ideas also need a new " +"place. It’s time to rebuild everything. Join us." msgstr "" #: funnel/templates/about.html.jinja2:30 funnel/templates/about.html.jinja2:33 @@ -2143,10 +2231,19 @@ msgid "" "conversation or an opportunity to collaborate." msgstr "" +#: funnel/templates/account.html.jinja2:36 +#: funnel/templates/account_menu.html.jinja2:35 +msgid "Add username" +msgstr "" + #: funnel/templates/account.html.jinja2:42 msgid "Go to account" msgstr "" +#: funnel/templates/account.html.jinja2:51 +msgid "Info" +msgstr "" + #: funnel/templates/account.html.jinja2:63 #: funnel/templates/account_merge.html.jinja2:8 #: funnel/templates/account_merge.html.jinja2:14 @@ -2161,27 +2258,31 @@ msgstr "" #: funnel/templates/account.html.jinja2:90 #: funnel/templates/auth_client.html.jinja2:169 #: funnel/templates/js/comments.js.jinja2:88 -#: funnel/templates/labels.html.jinja2:48 +#: funnel/templates/labels.html.jinja2:75 #: funnel/templates/organization_teams.html.jinja2:41 #: funnel/templates/project_admin.html.jinja2:28 #: funnel/templates/project_admin.html.jinja2:55 #: funnel/templates/project_admin.html.jinja2:77 -#: funnel/templates/submission_form.html.jinja2:16 -#: funnel/templates/submission_form.html.jinja2:44 -#: funnel/templates/ticket_event.html.jinja2:27 +#: funnel/templates/submission_form.html.jinja2:20 +#: funnel/templates/submission_form.html.jinja2:39 +#: funnel/templates/ticket_event.html.jinja2:31 +#: funnel/templates/ticket_type.html.jinja2:15 +#: funnel/templates/venues.html.jinja2:25 +#: funnel/templates/venues.html.jinja2:57 msgid "Edit" msgstr "" -#: funnel/templates/account.html.jinja2:95 funnel/views/account.py:444 +#: funnel/templates/account.html.jinja2:95 funnel/views/account.py:453 msgid "Change password" msgstr "" -#: funnel/templates/account.html.jinja2:97 funnel/views/account.py:441 +#: funnel/templates/account.html.jinja2:97 funnel/views/account.py:450 msgid "Set password" msgstr "" #: funnel/templates/account.html.jinja2:103 -#: funnel/templates/account.html.jinja2:346 +#: funnel/templates/account.html.jinja2:357 +#: funnel/templates/account.html.jinja2:358 #: funnel/templates/account_menu.html.jinja2:112 msgid "Logout" msgstr "" @@ -2196,19 +2297,19 @@ msgid "Last used %(last_used_at)s" msgstr "" #: funnel/templates/account.html.jinja2:122 -#: funnel/templates/account.html.jinja2:172 -#: funnel/templates/account.html.jinja2:181 -#: funnel/templates/account.html.jinja2:231 +#: funnel/templates/account.html.jinja2:173 +#: funnel/templates/account.html.jinja2:182 +#: funnel/templates/account.html.jinja2:232 #: funnel/templates/collaborator_list.html.jinja2:30 #: funnel/templates/project_sponsor_popup.html.jinja2:22 -#: funnel/views/account.py:592 funnel/views/account.py:735 -#: funnel/views/account.py:767 funnel/views/membership.py:296 -#: funnel/views/membership.py:582 +#: funnel/views/account.py:609 funnel/views/account.py:752 +#: funnel/views/account.py:784 funnel/views/membership.py:289 +#: funnel/views/membership.py:575 msgid "Remove" msgstr "" #: funnel/templates/account.html.jinja2:134 -#: funnel/templates/password_login_form.html.jinja2:73 +#: funnel/templates/password_login_form.html.jinja2:75 #, python-format msgid "Login using %(title)s" msgstr "" @@ -2217,22 +2318,26 @@ msgstr "" msgid "Email addresses" msgstr "" -#: funnel/templates/account.html.jinja2:166 -#: funnel/templates/account.html.jinja2:227 +#: funnel/templates/account.html.jinja2:167 +#: funnel/templates/account.html.jinja2:228 msgid "Primary" msgstr "" -#: funnel/templates/account.html.jinja2:179 +#: funnel/templates/account.html.jinja2:180 msgid "(pending verification)" msgstr "" -#: funnel/templates/account.html.jinja2:194 -#: funnel/templates/account.html.jinja2:245 +#: funnel/templates/account.html.jinja2:193 +msgid "Set as primary email" +msgstr "" + +#: funnel/templates/account.html.jinja2:195 +#: funnel/templates/account.html.jinja2:246 #: funnel/templates/venues.html.jinja2:36 msgid "Set as primary" msgstr "" -#: funnel/templates/account.html.jinja2:199 funnel/views/account.py:496 +#: funnel/templates/account.html.jinja2:199 funnel/views/account.py:505 msgid "Add an email address" msgstr "" @@ -2240,87 +2345,91 @@ msgstr "" msgid "Mobile numbers" msgstr "" -#: funnel/templates/account.html.jinja2:249 +#: funnel/templates/account.html.jinja2:250 msgid "Add a mobile number" msgstr "" -#: funnel/templates/account.html.jinja2:261 +#: funnel/templates/account.html.jinja2:262 msgid "Connected apps" msgstr "" -#: funnel/templates/account.html.jinja2:273 +#: funnel/templates/account.html.jinja2:274 +#: funnel/templates/account.html.jinja2:275 msgid "Made by Hasgeek" msgstr "" -#: funnel/templates/account.html.jinja2:283 +#: funnel/templates/account.html.jinja2:285 #, python-format msgid "Since %(since)s – last used %(last_used)s" msgstr "" -#: funnel/templates/account.html.jinja2:285 +#: funnel/templates/account.html.jinja2:287 #, python-format msgid "Since %(since)s" msgstr "" -#: funnel/templates/account.html.jinja2:305 +#: funnel/templates/account.html.jinja2:295 +#: funnel/templates/auth_client.html.jinja2:99 funnel/views/auth_client.py:203 +msgid "Disconnect" +msgstr "" + +#: funnel/templates/account.html.jinja2:310 msgid "Login sessions" msgstr "" -#: funnel/templates/account.html.jinja2:323 +#: funnel/templates/account.html.jinja2:334 #, python-format msgid "%(browser)s on %(device)s" msgstr "" -#: funnel/templates/account.html.jinja2:330 +#: funnel/templates/account.html.jinja2:341 #, python-format msgid "Since %(since)s via %(login_service)s – last active %(last_active)s" msgstr "" -#: funnel/templates/account.html.jinja2:332 +#: funnel/templates/account.html.jinja2:343 #, python-format msgid "Since %(since)s – last active %(last_active)s" msgstr "" -#: funnel/templates/account.html.jinja2:336 +#: funnel/templates/account.html.jinja2:347 #, python-format msgid "%(location)s – estimated from %(ipaddr)s" msgstr "" -#: funnel/templates/account_formlayout.html.jinja2:21 -#: funnel/templates/account_formlayout.html.jinja2:28 -#: funnel/templates/img_upload_formlayout.html.jinja2:10 +#: funnel/templates/account_formlayout.html.jinja2:22 +#: funnel/templates/account_formlayout.html.jinja2:29 +#: funnel/templates/img_upload_formlayout.html.jinja2:9 #: funnel/templates/labels_form.html.jinja2:23 +#: funnel/templates/labels_form.html.jinja2:34 #: funnel/templates/macros.html.jinja2:13 -#: funnel/templates/macros.html.jinja2:415 -#: funnel/templates/macros.html.jinja2:479 +#: funnel/templates/modalajaxform.html.jinja2:5 +#: funnel/templates/profile_layout.html.jinja2:15 +#: funnel/templates/profile_layout.html.jinja2:79 #: funnel/templates/project_cfp.html.jinja2:34 -#: funnel/templates/project_layout.html.jinja2:141 -#: funnel/templates/project_layout.html.jinja2:164 +#: funnel/templates/project_layout.html.jinja2:136 +#: funnel/templates/project_layout.html.jinja2:159 #: funnel/templates/project_sponsor_popup.html.jinja2:7 #: funnel/templates/project_sponsor_popup.html.jinja2:21 -#: funnel/templates/schedule_edit.html.jinja2:99 +#: funnel/templates/schedule_edit.html.jinja2:106 #: funnel/templates/schedule_subscribe.html.jinja2:4 #: funnel/templates/session_view_popup.html.jinja2:4 #: funnel/templates/submission_admin_panel.html.jinja2:7 -#: funnel/templates/submission_form.html.jinja2:108 +#: funnel/templates/submission_form.html.jinja2:120 #: funnel/templates/update_logo_modal.html.jinja2:8 msgid "Close" msgstr "" -#: funnel/templates/account_formlayout.html.jinja2:22 +#: funnel/templates/account_formlayout.html.jinja2:23 msgid "" "Cookies are required to login. Please enable cookies in your browser’s " "settings and reload this page" msgstr "" -#: funnel/templates/account_menu.html.jinja2:35 -msgid "Add username" -msgstr "" - #: funnel/templates/account_menu.html.jinja2:43 #: funnel/templates/account_menu.html.jinja2:96 #: funnel/templates/account_organizations.html.jinja2:4 -#: funnel/templates/macros.html.jinja2:143 +#: funnel/templates/macros.html.jinja2:133 msgid "Organizations" msgstr "" @@ -2338,12 +2447,12 @@ msgid "Notification settings" msgstr "" #: funnel/templates/account_menu.html.jinja2:102 -#: funnel/templates/macros.html.jinja2:145 +#: funnel/templates/macros.html.jinja2:135 msgid "Saved projects" msgstr "" #: funnel/templates/account_merge.html.jinja2:3 -#: funnel/templates/account_merge.html.jinja2:49 funnel/views/login.py:632 +#: funnel/templates/account_merge.html.jinja2:49 funnel/views/login.py:643 msgid "Merge accounts" msgstr "" @@ -2384,7 +2493,7 @@ msgstr "" msgid "Add new organization" msgstr "" -#: funnel/templates/account_organizations.html.jinja2:54 +#: funnel/templates/account_organizations.html.jinja2:59 #: funnel/templates/js/membership.js.jinja2:100 msgid "Admin" msgstr "" @@ -2403,7 +2512,7 @@ msgstr "" msgid "Edit this application" msgstr "" -#: funnel/templates/auth_client.html.jinja2:18 funnel/views/auth_client.py:228 +#: funnel/templates/auth_client.html.jinja2:18 funnel/views/auth_client.py:229 msgid "New access key" msgstr "" @@ -2459,10 +2568,6 @@ msgstr "" msgid "Last used" msgstr "" -#: funnel/templates/auth_client.html.jinja2:99 funnel/views/auth_client.py:202 -msgid "Disconnect" -msgstr "" - #: funnel/templates/auth_client.html.jinja2:110 msgid "Access keys" msgstr "" @@ -2570,6 +2675,10 @@ msgstr "" msgid "Error URI" msgstr "" +#: funnel/templates/badge.html.jinja2:4 +msgid "Badge" +msgstr "" + #: funnel/templates/collaborator_list.html.jinja2:21 msgid "Visible" msgstr "" @@ -2579,7 +2688,8 @@ msgid "Collaborator menu" msgstr "" #: funnel/templates/collaborator_list.html.jinja2:29 -#: funnel/templates/submission_form.html.jinja2:82 +#: funnel/templates/submission_form.html.jinja2:85 +#: funnel/templates/submission_form.html.jinja2:100 msgid "Add collaborator" msgstr "" @@ -2613,6 +2723,10 @@ msgstr "" msgid "Download contacts CSV" msgstr "" +#: funnel/templates/contacts.html.jinja2:77 +msgid "Download contact" +msgstr "" + #: funnel/templates/delete.html.jinja2:9 #: funnel/templates/project_sponsor_popup.html.jinja2:19 msgid "" @@ -2659,7 +2773,7 @@ msgid "Confirm your email address" msgstr "" #: funnel/templates/email_login_otp.html.jinja2:7 -#: funnel/templates/login.html.jinja2:17 +#: funnel/templates/login.html.jinja2:21 msgid "Hello!" msgstr "" @@ -2667,43 +2781,34 @@ msgstr "" msgid "This login OTP is valid for 15 minutes." msgstr "" -#: funnel/templates/email_project_crew_membership_add_notification.html.jinja2:4 -#, python-format +#: funnel/templates/email_sudo_otp.html.jinja2:6 msgid "" -"\n" -" %(actor)s has added you to ‘%(project)s’ as a crew member.\n" -" " +"You are about to perform a critical action. This OTP serves as your " +"confirmation to proceed and is valid for 15 minutes." msgstr "" -#: funnel/templates/email_project_crew_membership_add_notification.html.jinja2:9 -#: funnel/templates/email_project_crew_membership_revoke_notification.html.jinja2:9 -msgid "See all crew members" +#: funnel/templates/forms.html.jinja2:66 +msgid "Enter a location" msgstr "" -#: funnel/templates/email_project_crew_membership_invite_notification.html.jinja2:4 -#, python-format -msgid "" -"\n" -" %(actor)s has invited you to join ‘%(project)s’ as a crew member.\n" -" " +#: funnel/templates/forms.html.jinja2:67 +msgid "Clear location" msgstr "" -#: funnel/templates/email_project_crew_membership_invite_notification.html.jinja2:9 -msgid "Accept or decline invite" +#: funnel/templates/forms.html.jinja2:79 +msgid "switch to alphabet keyboard" msgstr "" -#: funnel/templates/email_project_crew_membership_revoke_notification.html.jinja2:4 -#, python-format -msgid "" -"\n" -" %(actor)s has removed you as a crew member from ‘%(project)s’.\n" -" " +#: funnel/templates/forms.html.jinja2:80 +msgid "switch to numeric keyboard" msgstr "" -#: funnel/templates/email_sudo_otp.html.jinja2:6 -msgid "" -"You are about to perform a critical action. This OTP serves as your " -"confirmation to proceed and is valid for 15 minutes." +#: funnel/templates/forms.html.jinja2:93 +msgid "Show password" +msgstr "" + +#: funnel/templates/forms.html.jinja2:94 +msgid "Hide password" msgstr "" #: funnel/templates/forms.html.jinja2:154 @@ -2720,7 +2825,8 @@ msgstr "" msgid "Spotlight:" msgstr "" -#: funnel/templates/index.html.jinja2:58 funnel/templates/layout.html.jinja2:94 +#: funnel/templates/index.html.jinja2:58 +#: funnel/templates/layout.html.jinja2:118 msgid "What’s this about?" msgstr "" @@ -2728,25 +2834,30 @@ msgstr "" msgid "Explore communities" msgstr "" -#: funnel/templates/labels.html.jinja2:10 -#: funnel/templates/project_layout.html.jinja2:229 +#: funnel/templates/label_badge.html.jinja2:4 +msgid "Label badge" +msgstr "" + +#: funnel/templates/labels.html.jinja2:17 +#: funnel/templates/project_layout.html.jinja2:243 #: funnel/templates/submission_admin_panel.html.jinja2:24 msgid "Manage labels" msgstr "" -#: funnel/templates/labels.html.jinja2:22 +#: funnel/templates/labels.html.jinja2:32 +#: funnel/templates/labels.html.jinja2:34 msgid "Create new label" msgstr "" -#: funnel/templates/labels.html.jinja2:44 +#: funnel/templates/labels.html.jinja2:69 msgid "(No labels)" msgstr "" -#: funnel/templates/labels.html.jinja2:50 funnel/views/label.py:222 +#: funnel/templates/labels.html.jinja2:80 funnel/views/label.py:222 msgid "Archive" msgstr "" -#: funnel/templates/labels.html.jinja2:59 +#: funnel/templates/labels.html.jinja2:99 msgid "Save label sequence" msgstr "" @@ -2755,362 +2866,293 @@ msgstr "" msgid "Please review the indicated issues" msgstr "" +#: funnel/templates/labels_form.html.jinja2:51 +msgid "Add option" +msgstr "" + #: funnel/templates/labels_form.html.jinja2:66 +#: funnel/templates/submission_form.html.jinja2:64 +#: funnel/templates/submission_form.html.jinja2:79 msgid "Done" msgstr "" -#: funnel/templates/layout.html.jinja2:99 -msgid "Search the site" +#: funnel/templates/layout.html.jinja2:112 +#: funnel/templates/layout.html.jinja2:115 +#: funnel/templates/layout.html.jinja2:122 +#: funnel/templates/layout.html.jinja2:127 +#: funnel/templates/layout.html.jinja2:130 +#: funnel/templates/layout.html.jinja2:131 +#: funnel/templates/profile_layout.html.jinja2:136 +msgid "Home" +msgstr "" + +#: funnel/templates/layout.html.jinja2:137 +msgid "Search this site" msgstr "" -#: funnel/templates/layout.html.jinja2:99 +#: funnel/templates/layout.html.jinja2:138 msgid "Search…" msgstr "" -#: funnel/templates/layout.html.jinja2:104 +#: funnel/templates/layout.html.jinja2:149 +#: funnel/templates/layout.html.jinja2:150 #: funnel/templates/search.html.jinja2:7 funnel/templates/search.html.jinja2:8 +#: funnel/templates/siteadmin_comments.html.jinja2:17 +#: funnel/templates/ticket_event.html.jinja2:39 msgid "Search" msgstr "" -#: funnel/templates/layout.html.jinja2:106 -#: funnel/templates/layout.html.jinja2:119 +#: funnel/templates/layout.html.jinja2:155 +#: funnel/templates/layout.html.jinja2:156 +#: funnel/templates/layout.html.jinja2:184 #: funnel/templates/notification_feed.html.jinja2:5 -#: funnel/templates/project_layout.html.jinja2:450 -#: funnel/templates/project_updates.html.jinja2:9 funnel/views/search.py:499 +#: funnel/templates/project_layout.html.jinja2:456 +#: funnel/templates/project_updates.html.jinja2:9 funnel/views/search.py:511 msgid "Updates" msgstr "" -#: funnel/templates/layout.html.jinja2:108 -#: funnel/templates/layout.html.jinja2:121 +#: funnel/templates/layout.html.jinja2:165 +#: funnel/templates/layout.html.jinja2:192 #: funnel/templates/project_comments.html.jinja2:9 -#: funnel/templates/project_layout.html.jinja2:451 -#: funnel/templates/submission.html.jinja2:251 funnel/views/search.py:554 -#: funnel/views/siteadmin.py:255 +#: funnel/templates/project_layout.html.jinja2:457 +#: funnel/templates/submission.html.jinja2:411 funnel/views/search.py:570 +#: funnel/views/siteadmin.py:296 msgid "Comments" msgstr "" -#: funnel/templates/layout.html.jinja2:111 -#: funnel/templates/layout.html.jinja2:124 -#: funnel/templates/macros.html.jinja2:406 +#: funnel/templates/layout.html.jinja2:171 +#: funnel/templates/layout.html.jinja2:204 +#: funnel/templates/profile_layout.html.jinja2:6 msgid "Account menu" msgstr "" -#: funnel/templates/layout.html.jinja2:140 -#: funnel/templates/login.html.jinja2:27 funnel/views/login.py:318 +#: funnel/templates/layout.html.jinja2:222 +#: funnel/templates/login.html.jinja2:31 funnel/views/login.py:328 msgid "Login" msgstr "" -#: funnel/templates/login.html.jinja2:18 +#: funnel/templates/login.html.jinja2:22 msgid "Tell us where you’d like to get updates. We’ll send an OTP to confirm." msgstr "" -#: funnel/templates/login.html.jinja2:22 +#: funnel/templates/login.html.jinja2:26 msgid "Or, use your existing account, no OTP required" msgstr "" +#: funnel/templates/login_beacon.html.jinja2:4 +msgid "Login beacon" +msgstr "" + #: funnel/templates/logout_browser_data.html.jinja2:5 #: funnel/templates/logout_browser_data.html.jinja2:27 msgid "Logging out…" msgstr "" -#: funnel/templates/macros.html.jinja2:81 +#: funnel/templates/macros.html.jinja2:71 msgid "Login to save this project" msgstr "" -#: funnel/templates/macros.html.jinja2:84 +#: funnel/templates/macros.html.jinja2:74 msgid "Save this project" msgstr "" -#: funnel/templates/macros.html.jinja2:86 +#: funnel/templates/macros.html.jinja2:76 msgid "Unsave this project" msgstr "" -#: funnel/templates/macros.html.jinja2:95 +#: funnel/templates/macros.html.jinja2:85 msgid "Copy link" msgstr "" -#: funnel/templates/macros.html.jinja2:98 +#: funnel/templates/macros.html.jinja2:88 msgid "Facebook" msgstr "" -#: funnel/templates/macros.html.jinja2:110 -#: funnel/templates/project_layout.html.jinja2:74 -#: funnel/templates/project_layout.html.jinja2:90 +#: funnel/templates/macros.html.jinja2:100 +#: funnel/templates/project_layout.html.jinja2:79 +#: funnel/templates/project_layout.html.jinja2:95 msgid "Preview video" msgstr "" -#: funnel/templates/macros.html.jinja2:121 +#: funnel/templates/macros.html.jinja2:107 +msgid "Powered by VideoKen" +msgstr "" + +#: funnel/templates/macros.html.jinja2:111 msgid "Edit submission video" msgstr "" -#: funnel/templates/macros.html.jinja2:123 -#: funnel/templates/submission.html.jinja2:83 +#: funnel/templates/macros.html.jinja2:113 +#: funnel/templates/submission.html.jinja2:183 msgid "Edit session video" msgstr "" #: funnel/templates/js/comments.js.jinja2:81 -#: funnel/templates/macros.html.jinja2:132 -#: funnel/templates/macros.html.jinja2:134 -#: funnel/templates/project_layout.html.jinja2:209 +#: funnel/templates/macros.html.jinja2:122 +#: funnel/templates/macros.html.jinja2:124 +#: funnel/templates/project_layout.html.jinja2:197 #: funnel/templates/session_view_popup.html.jinja2:25 -#: funnel/templates/submission.html.jinja2:25 +#: funnel/templates/submission.html.jinja2:33 +#: funnel/templates/submission.html.jinja2:44 msgid "Share" msgstr "" -#: funnel/templates/macros.html.jinja2:144 +#: funnel/templates/macros.html.jinja2:134 msgid "Notifications" msgstr "" -#: funnel/templates/macros.html.jinja2:146 +#: funnel/templates/macros.html.jinja2:136 #: funnel/templates/scan_contact.html.jinja2:5 msgid "Scan badge" msgstr "" -#: funnel/templates/macros.html.jinja2:147 +#: funnel/templates/macros.html.jinja2:137 msgid "Contacts" msgstr "" -#: funnel/templates/macros.html.jinja2:202 -#: funnel/templates/macros.html.jinja2:749 -#: funnel/templates/macros.html.jinja2:776 +#: funnel/templates/macros.html.jinja2:192 +#: funnel/templates/macros.html.jinja2:597 +#: funnel/templates/macros.html.jinja2:624 #, python-format msgid "Accepting submissions till %(date)s" msgstr "" -#: funnel/templates/macros.html.jinja2:228 +#: funnel/templates/macros.html.jinja2:218 msgid "Live schedule" msgstr "" -#: funnel/templates/macros.html.jinja2:230 -#: funnel/templates/macros.html.jinja2:256 -#: funnel/templates/project_layout.html.jinja2:60 +#: funnel/templates/macros.html.jinja2:220 +#: funnel/templates/macros.html.jinja2:246 +#: funnel/templates/project_layout.html.jinja2:63 #: funnel/templates/project_settings.html.jinja2:53 msgid "Livestream" msgstr "" -#: funnel/templates/macros.html.jinja2:232 +#: funnel/templates/macros.html.jinja2:222 msgid "Livestream and schedule" msgstr "" -#: funnel/templates/macros.html.jinja2:252 +#: funnel/templates/macros.html.jinja2:242 #, python-format msgid "Session starts at %(session)s" msgstr "" -#: funnel/templates/macros.html.jinja2:256 +#: funnel/templates/macros.html.jinja2:246 msgid "Watch livestream" msgstr "" -#: funnel/templates/macros.html.jinja2:259 -#: funnel/templates/project_layout.html.jinja2:456 -#: funnel/templates/project_schedule.html.jinja2:9 -#: funnel/templates/project_schedule.html.jinja2:72 +#: funnel/templates/macros.html.jinja2:249 +#: funnel/templates/project_layout.html.jinja2:462 +#: funnel/templates/project_schedule.html.jinja2:12 +#: funnel/templates/project_schedule.html.jinja2:86 #: funnel/templates/project_settings.html.jinja2:68 #: funnel/templates/schedule_edit.html.jinja2:3 msgid "Schedule" msgstr "" -#: funnel/templates/macros.html.jinja2:277 +#: funnel/templates/macros.html.jinja2:267 msgid "Spotlight" msgstr "" -#: funnel/templates/macros.html.jinja2:313 +#: funnel/templates/macros.html.jinja2:303 msgid "Learn more" msgstr "" -#: funnel/templates/macros.html.jinja2:359 -#: funnel/templates/macros.html.jinja2:751 +#: funnel/templates/macros.html.jinja2:349 +#: funnel/templates/macros.html.jinja2:599 msgid "Accepting submissions" msgstr "" -#: funnel/templates/macros.html.jinja2:373 -#: funnel/templates/profile.html.jinja2:94 -#: funnel/templates/profile.html.jinja2:116 -#: funnel/templates/profile.html.jinja2:158 -#: funnel/templates/profile.html.jinja2:182 +#: funnel/templates/macros.html.jinja2:363 +#: funnel/templates/profile.html.jinja2:93 +#: funnel/templates/profile.html.jinja2:115 +#: funnel/templates/profile.html.jinja2:157 +#: funnel/templates/profile.html.jinja2:181 msgid "Show more" msgstr "" -#: funnel/templates/macros.html.jinja2:388 +#: funnel/templates/macros.html.jinja2:378 msgid "All projects" msgstr "" -#: funnel/templates/macros.html.jinja2:408 -msgid "Manage admins" +#: funnel/templates/macros.html.jinja2:398 +msgid "Team & careers" msgstr "" -#: funnel/templates/macros.html.jinja2:408 -msgid "View admins" +#: funnel/templates/macros.html.jinja2:399 +msgid "Contact" msgstr "" -#: funnel/templates/macros.html.jinja2:409 -msgid "Edit this account" +#: funnel/templates/macros.html.jinja2:400 +#: funnel/templates/policy.html.jinja2:19 +msgid "Site policies" msgstr "" -#: funnel/templates/macros.html.jinja2:410 -msgid "Make account private" +#: funnel/templates/macros.html.jinja2:446 +msgid "(No sessions have been submitted)" msgstr "" -#: funnel/templates/macros.html.jinja2:416 -msgid "Make this account private?" +#: funnel/templates/macros.html.jinja2:474 +#: funnel/templates/project_layout.html.jinja2:334 +msgid "Supported by" msgstr "" -#: funnel/templates/macros.html.jinja2:420 -msgid "Your account will not be visible to anyone other than you" +#: funnel/templates/macros.html.jinja2:529 +msgid "Video thumbnail" msgstr "" -#: funnel/templates/macros.html.jinja2:421 -msgid "It will not be listed in search results" +#: funnel/templates/macros.html.jinja2:537 +#: funnel/templates/project.html.jinja2:117 +#: funnel/templates/project_layout.html.jinja2:34 +#: funnel/templates/project_layout.html.jinja2:329 +#: funnel/templates/project_layout.html.jinja2:373 +msgid "more" msgstr "" -#: funnel/templates/macros.html.jinja2:422 -msgid "You cannot host projects from this account" +#: funnel/templates/macros.html.jinja2:546 +#, python-format +msgid "%(count)s comment" msgstr "" -#: funnel/templates/macros.html.jinja2:423 -msgid "" -"Any existing projects will become inaccessible until the account is " -"public again" +#: funnel/templates/macros.html.jinja2:556 +msgid "This proposal has a preview video" msgstr "" -#: funnel/templates/macros.html.jinja2:436 -msgid "Back to the account" +#: funnel/templates/macros.html.jinja2:604 +msgid "Not accepting submissions" msgstr "" -#: funnel/templates/macros.html.jinja2:456 -msgid "Add cover photo url" +#: funnel/templates/macros.html.jinja2:614 +msgid "Toggle to enable/disable submissions" msgstr "" -#: funnel/templates/macros.html.jinja2:456 -msgid "Add cover photo" +#: funnel/templates/macros.html.jinja2:614 +msgid "Open to receive submissions" msgstr "" -#: funnel/templates/macros.html.jinja2:474 -#: funnel/templates/macros.html.jinja2:522 -#: funnel/templates/profile.html.jinja2:145 -msgid "New project" +#: funnel/templates/macros.html.jinja2:623 +msgid "Make a submission" msgstr "" -#: funnel/templates/macros.html.jinja2:476 -#: funnel/templates/macros.html.jinja2:524 -msgid "Make account public" +#: funnel/templates/macros.html.jinja2:634 +msgid "Past sessions" msgstr "" -#: funnel/templates/macros.html.jinja2:480 -msgid "Make this account public?" +#: funnel/templates/macros.html.jinja2:640 +#: funnel/templates/past_projects_section.html.jinja2:3 +msgid "Date" msgstr "" -#: funnel/templates/macros.html.jinja2:484 -msgid "Your account will be visible to anyone visiting the page" +#: funnel/templates/macros.html.jinja2:641 +#: funnel/templates/past_projects_section.html.jinja2:6 +msgid "Project" msgstr "" -#: funnel/templates/macros.html.jinja2:485 -msgid "Your account will be listed in search results" -msgstr "" - -#: funnel/templates/macros.html.jinja2:514 -#, python-format -msgid "Joined %(date)s" -msgstr "" - -#: funnel/templates/macros.html.jinja2:537 -#: funnel/templates/organization_membership.html.jinja2:16 -msgid "Admins" -msgstr "" - -#: funnel/templates/macros.html.jinja2:539 -#: funnel/templates/project.html.jinja2:94 funnel/views/search.py:355 -msgid "Sessions" -msgstr "" - -#: funnel/templates/macros.html.jinja2:540 -#: funnel/templates/user_profile_projects.html.jinja2:6 -#: funnel/views/search.py:193 -msgid "Projects" -msgstr "" - -#: funnel/templates/macros.html.jinja2:541 -#: funnel/templates/project_layout.html.jinja2:453 -#: funnel/templates/project_settings.html.jinja2:58 -#: funnel/templates/project_submissions.html.jinja2:8 -#: funnel/templates/user_profile_proposals.html.jinja2:6 -#: funnel/views/search.py:412 -msgid "Submissions" -msgstr "" - -#: funnel/templates/macros.html.jinja2:553 -msgid "Team & careers" -msgstr "" - -#: funnel/templates/macros.html.jinja2:554 -msgid "Contact" -msgstr "" - -#: funnel/templates/macros.html.jinja2:555 -#: funnel/templates/policy.html.jinja2:19 -msgid "Site policies" -msgstr "" - -#: funnel/templates/macros.html.jinja2:601 -msgid "(No sessions have been submitted)" -msgstr "" - -#: funnel/templates/macros.html.jinja2:629 -#: funnel/templates/project_layout.html.jinja2:343 -msgid "Supported by" -msgstr "" - -#: funnel/templates/macros.html.jinja2:684 -msgid "Video thumbnail" -msgstr "" - -#: funnel/templates/macros.html.jinja2:692 -#: funnel/templates/project.html.jinja2:117 -#: funnel/templates/project_layout.html.jinja2:34 -#: funnel/templates/project_layout.html.jinja2:338 -#: funnel/templates/project_layout.html.jinja2:371 -msgid "more" -msgstr "" - -#: funnel/templates/macros.html.jinja2:708 -msgid "This proposal has a preview video" -msgstr "" - -#: funnel/templates/macros.html.jinja2:756 -msgid "Not accepting submissions" -msgstr "" - -#: funnel/templates/macros.html.jinja2:766 -msgid "Toggle to enable/disable submissions" -msgstr "" - -#: funnel/templates/macros.html.jinja2:766 -msgid "Open to receive submissions" -msgstr "" - -#: funnel/templates/macros.html.jinja2:775 -msgid "Make a submission" -msgstr "" - -#: funnel/templates/macros.html.jinja2:786 -msgid "Past sessions" -msgstr "" - -#: funnel/templates/macros.html.jinja2:792 -#: funnel/templates/past_projects_section.html.jinja2:3 -msgid "Date" -msgstr "" - -#: funnel/templates/macros.html.jinja2:793 -#: funnel/templates/past_projects_section.html.jinja2:6 -msgid "Project" -msgstr "" - -#: funnel/templates/macros.html.jinja2:826 +#: funnel/templates/macros.html.jinja2:674 msgid "One project" msgstr "" -#: funnel/templates/macros.html.jinja2:827 +#: funnel/templates/macros.html.jinja2:675 msgid "Explore" msgstr "" @@ -3126,7 +3168,7 @@ msgstr "" #: funnel/templates/meta_refresh.html.jinja2:7 #: funnel/templates/meta_refresh.html.jinja2:29 #: funnel/templates/project.html.jinja2:174 -#: funnel/templates/project_layout.html.jinja2:420 +#: funnel/templates/project_layout.html.jinja2:426 #: funnel/templates/redirect.html.jinja2:1 msgid "Loading…" msgstr "" @@ -3139,7 +3181,7 @@ msgstr "" msgid "To receive timely notifications by SMS, add a phone number" msgstr "" -#: funnel/templates/notification_preferences.html.jinja2:77 +#: funnel/templates/notification_preferences.html.jinja2:81 msgid "No notifications in this category" msgstr "" @@ -3196,6 +3238,7 @@ msgstr "" #: funnel/templates/js/badge.js.jinja2:37 #: funnel/templates/opensearch.xml.jinja2:3 +#: funnel/templates/opensearch.xml.jinja2:7 msgid "Hasgeek" msgstr "" @@ -3203,6 +3246,11 @@ msgstr "" msgid "Search Hasgeek for projects, discussions and more" msgstr "" +#: funnel/templates/organization_membership.html.jinja2:13 +#: funnel/templates/profile_layout.html.jinja2:137 +msgid "Admins" +msgstr "" + #: funnel/templates/organization_teams.html.jinja2:3 #: funnel/templates/organization_teams.html.jinja2:13 msgid "Teams" @@ -3216,22 +3264,22 @@ msgstr "" msgid "Linked apps" msgstr "" -#: funnel/templates/password_login_form.html.jinja2:19 -#: funnel/templates/password_login_form.html.jinja2:33 +#: funnel/templates/password_login_form.html.jinja2:20 +#: funnel/templates/password_login_form.html.jinja2:34 msgid "Use OTP" msgstr "" -#: funnel/templates/password_login_form.html.jinja2:22 -#: funnel/templates/password_login_form.html.jinja2:30 +#: funnel/templates/password_login_form.html.jinja2:23 +#: funnel/templates/password_login_form.html.jinja2:31 msgid "Have a password?" msgstr "" -#: funnel/templates/password_login_form.html.jinja2:26 -#: funnel/templates/password_login_form.html.jinja2:37 +#: funnel/templates/password_login_form.html.jinja2:27 +#: funnel/templates/password_login_form.html.jinja2:38 msgid "Forgot password?" msgstr "" -#: funnel/templates/password_login_form.html.jinja2:62 +#: funnel/templates/password_login_form.html.jinja2:63 #, python-format msgid "" "By signing in, you agree to Hasgeek’s %(project)s starts at %(start_time)s" msgstr "" -#: funnel/templates/notifications/project_starting_email.html.jinja2:12 +#: funnel/templates/notifications/project_starting_email.html.jinja2:11 msgid "Join now" msgstr "" @@ -4233,12 +4462,12 @@ msgstr "" msgid "%(project)s starts at %(start_time)s" msgstr "" -#: funnel/templates/notifications/proposal_received_email.html.jinja2:5 +#: funnel/templates/notifications/proposal_received_email.html.jinja2:4 #, python-format msgid "Your project %(project)s has a new submission: %(proposal)s" msgstr "" -#: funnel/templates/notifications/proposal_received_email.html.jinja2:7 +#: funnel/templates/notifications/proposal_received_email.html.jinja2:6 msgid "Submission page" msgstr "" @@ -4266,63 +4495,61 @@ msgid "" "%(actor)s" msgstr "" -#: funnel/templates/notifications/proposal_submitted_email.html.jinja2:5 +#: funnel/templates/notifications/proposal_submitted_email.html.jinja2:4 #, python-format -msgid "" -"You have submitted a new proposal %(proposal)s to the project " -"%(project)s" +msgid "You have submitted %(proposal)s to the project %(project)s" msgstr "" -#: funnel/templates/notifications/proposal_submitted_email.html.jinja2:7 -msgid "View proposal" +#: funnel/templates/notifications/proposal_submitted_email.html.jinja2:6 +msgid "View submission" msgstr "" -#: funnel/templates/notifications/proposal_submitted_web.html.jinja2:5 +#: funnel/templates/notifications/proposal_submitted_web.html.jinja2:4 #, python-format msgid "" "You submitted %(proposal)s to %(project)s" msgstr "" -#: funnel/templates/notifications/rsvp_no_email.html.jinja2:5 +#: funnel/templates/notifications/rsvp_no_email.html.jinja2:4 #, python-format msgid "" "You have cancelled your registration for %(project)s. If this was " "accidental, you can register again." msgstr "" -#: funnel/templates/notifications/rsvp_no_email.html.jinja2:7 -#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:12 +#: funnel/templates/notifications/rsvp_no_email.html.jinja2:6 +#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:11 msgid "Project page" msgstr "" -#: funnel/templates/notifications/rsvp_no_web.html.jinja2:5 +#: funnel/templates/notifications/rsvp_no_web.html.jinja2:4 #, python-format msgid "You cancelled your registration for %(project)s" msgstr "" -#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:6 +#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:5 #, python-format msgid "You have registered for %(project)s" msgstr "" -#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:9 +#: funnel/templates/notifications/rsvp_yes_email.html.jinja2:8 #, python-format msgid "The next session in the schedule starts %(date_and_time)s" msgstr "" -#: funnel/templates/notifications/rsvp_yes_web.html.jinja2:5 +#: funnel/templates/notifications/rsvp_yes_web.html.jinja2:4 #, python-format msgid "You registered for %(project)s" msgstr "" -#: funnel/templates/notifications/update_new_email.html.jinja2:5 -#: funnel/templates/notifications/update_new_web.html.jinja2:5 +#: funnel/templates/notifications/update_new_email.html.jinja2:4 +#: funnel/templates/notifications/update_new_web.html.jinja2:4 #, python-format msgid "%(actor)s posted an update in %(project)s:" msgstr "" -#: funnel/templates/notifications/update_new_email.html.jinja2:11 +#: funnel/templates/notifications/update_new_email.html.jinja2:10 msgid "Read on the website" msgstr "" @@ -4339,9 +4566,9 @@ msgid "" msgstr "" #: funnel/templates/notifications/user_password_set_email.html.jinja2:13 -#: funnel/views/account_reset.py:111 funnel/views/account_reset.py:288 -#: funnel/views/account_reset.py:290 funnel/views/email.py:45 -#: funnel/views/otp.py:464 +#: funnel/views/account_reset.py:111 funnel/views/account_reset.py:290 +#: funnel/views/account_reset.py:292 funnel/views/email.py:44 +#: funnel/views/otp.py:483 msgid "Reset password" msgstr "" @@ -4354,258 +4581,266 @@ msgstr "" msgid "Your password has been updated" msgstr "" -#: funnel/transports/sms/send.py:120 +#: funnel/transports/sms/send.py:53 funnel/transports/sms/send.py:65 +msgid "This phone number is not available" +msgstr "" + +#: funnel/transports/sms/send.py:58 funnel/transports/sms/send.py:215 +msgid "This phone number has been blocked" +msgstr "" + +#: funnel/transports/sms/send.py:61 +msgid "This phone number cannot receive text messages" +msgstr "" + +#: funnel/transports/sms/send.py:149 msgid "Unparseable response from Exotel" msgstr "" -#: funnel/transports/sms/send.py:123 +#: funnel/transports/sms/send.py:153 msgid "Exotel API error" msgstr "" -#: funnel/transports/sms/send.py:125 +#: funnel/transports/sms/send.py:155 msgid "Exotel not reachable" msgstr "" -#: funnel/transports/sms/send.py:165 +#: funnel/transports/sms/send.py:201 msgid "This phone number is invalid" msgstr "" -#: funnel/transports/sms/send.py:169 +#: funnel/transports/sms/send.py:207 msgid "" "Hasgeek cannot send messages to phone numbers in this country.Please " "contact support via email at {email} if this affects youruse of the site" msgstr "" -#: funnel/transports/sms/send.py:177 -msgid "This phone number has been blocked" -msgstr "" - -#: funnel/transports/sms/send.py:182 +#: funnel/transports/sms/send.py:222 msgid "This phone number is unsupported at this time" msgstr "" -#: funnel/transports/sms/send.py:190 +#: funnel/transports/sms/send.py:230 msgid "Hasgeek was unable to send a message to this phone number" msgstr "" -#: funnel/transports/sms/send.py:235 +#: funnel/transports/sms/send.py:281 msgid "No service provider available for this recipient" msgstr "" -#: funnel/views/account.py:201 +#: funnel/views/account.py:211 msgid "Unknown browser" msgstr "" -#: funnel/views/account.py:228 +#: funnel/views/account.py:238 msgid "Unknown device" msgstr "" -#: funnel/views/account.py:236 funnel/views/account.py:241 +#: funnel/views/account.py:246 funnel/views/account.py:251 msgid "Unknown location" msgstr "" -#: funnel/views/account.py:252 +#: funnel/views/account.py:262 msgid "Unknown ISP" msgstr "" -#: funnel/views/account.py:340 +#: funnel/views/account.py:350 msgid "Your account has been updated" msgstr "" -#: funnel/views/account.py:345 +#: funnel/views/account.py:355 msgid "Edit account" msgstr "" -#: funnel/views/account.py:349 funnel/views/auth_client.py:162 -#: funnel/views/auth_client.py:397 funnel/views/auth_client.py:472 -#: funnel/views/profile.py:286 funnel/views/project.py:342 -#: funnel/views/project.py:370 funnel/views/project.py:394 -#: funnel/views/project.py:523 funnel/views/proposal.py:404 -#: funnel/views/ticket_event.py:191 funnel/views/ticket_event.py:278 -#: funnel/views/ticket_event.py:339 funnel/views/ticket_participant.py:211 +#: funnel/views/account.py:359 funnel/views/auth_client.py:163 +#: funnel/views/auth_client.py:398 funnel/views/auth_client.py:473 +#: funnel/views/profile.py:286 funnel/views/project.py:356 +#: funnel/views/project.py:384 funnel/views/project.py:408 +#: funnel/views/project.py:543 funnel/views/proposal.py:416 +#: funnel/views/ticket_event.py:190 funnel/views/ticket_event.py:278 +#: funnel/views/ticket_event.py:340 funnel/views/ticket_participant.py:211 msgid "Save changes" msgstr "" -#: funnel/views/account.py:375 +#: funnel/views/account.py:385 msgid "Email address already claimed" msgstr "" -#: funnel/views/account.py:377 +#: funnel/views/account.py:387 msgid "" "The email address {email} has already been verified by " "another user" msgstr "" -#: funnel/views/account.py:384 +#: funnel/views/account.py:394 msgid "Email address already verified" msgstr "" -#: funnel/views/account.py:386 +#: funnel/views/account.py:396 msgid "" "Hello, {fullname}! Your email address {email} has already " "been verified" msgstr "" -#: funnel/views/account.py:407 +#: funnel/views/account.py:416 msgid "Email address verified" msgstr "" -#: funnel/views/account.py:409 +#: funnel/views/account.py:418 msgid "" "Hello, {fullname}! Your email address {email} has now been " "verified" msgstr "" -#: funnel/views/account.py:420 +#: funnel/views/account.py:429 msgid "This was not for you" msgstr "" -#: funnel/views/account.py:421 +#: funnel/views/account.py:430 msgid "" "You’ve opened an email verification link that was meant for another user." " If you are managing multiple accounts, please login with the correct " "account and open the link again" msgstr "" -#: funnel/views/account.py:429 +#: funnel/views/account.py:438 msgid "Expired confirmation link" msgstr "" -#: funnel/views/account.py:430 +#: funnel/views/account.py:439 msgid "The confirmation link you clicked on is either invalid or has expired" msgstr "" -#: funnel/views/account.py:457 +#: funnel/views/account.py:466 msgid "Your new password has been saved" msgstr "" -#: funnel/views/account.py:491 +#: funnel/views/account.py:500 msgid "We sent you an email to confirm your address" msgstr "" -#: funnel/views/account.py:498 +#: funnel/views/account.py:507 msgid "Add email" msgstr "" -#: funnel/views/account.py:511 +#: funnel/views/account.py:522 msgid "This is already your primary email address" msgstr "" -#: funnel/views/account.py:518 +#: funnel/views/account.py:531 msgid "Your primary email address has been updated" msgstr "" -#: funnel/views/account.py:521 +#: funnel/views/account.py:534 msgid "No such email address is linked to this user account" msgstr "" -#: funnel/views/account.py:524 +#: funnel/views/account.py:537 msgid "Please select an email address" msgstr "" -#: funnel/views/account.py:535 +#: funnel/views/account.py:550 msgid "This is already your primary phone number" msgstr "" -#: funnel/views/account.py:542 +#: funnel/views/account.py:559 msgid "Your primary phone number has been updated" msgstr "" -#: funnel/views/account.py:545 +#: funnel/views/account.py:562 msgid "No such phone number is linked to this user account" msgstr "" -#: funnel/views/account.py:548 +#: funnel/views/account.py:565 msgid "Please select a phone number" msgstr "" -#: funnel/views/account.py:574 +#: funnel/views/account.py:591 msgid "Your account requires at least one verified email address or phone number" msgstr "" -#: funnel/views/account.py:584 funnel/views/account.py:727 -#: funnel/views/account.py:757 funnel/views/membership.py:292 -#: funnel/views/membership.py:578 +#: funnel/views/account.py:601 funnel/views/account.py:744 +#: funnel/views/account.py:774 funnel/views/membership.py:285 +#: funnel/views/membership.py:571 msgid "Confirm removal" msgstr "" -#: funnel/views/account.py:585 +#: funnel/views/account.py:602 msgid "Remove email address {email} from your account?" msgstr "" -#: funnel/views/account.py:588 +#: funnel/views/account.py:605 msgid "You have removed your email address {email}" msgstr "" -#: funnel/views/account.py:619 +#: funnel/views/account.py:636 msgid "This email address is already verified" msgstr "" -#: funnel/views/account.py:635 +#: funnel/views/account.py:652 msgid "The verification email has been sent to this address" msgstr "" -#: funnel/views/account.py:639 +#: funnel/views/account.py:656 msgid "Resend the verification email?" msgstr "" -#: funnel/views/account.py:640 +#: funnel/views/account.py:657 msgid "We will resend the verification email to {email}" msgstr "" -#: funnel/views/account.py:644 +#: funnel/views/account.py:661 msgid "Send" msgstr "" -#: funnel/views/account.py:663 +#: funnel/views/account.py:680 msgid "Add a phone number" msgstr "" -#: funnel/views/account.py:665 +#: funnel/views/account.py:682 msgid "Verify phone" msgstr "" -#: funnel/views/account.py:676 funnel/views/account_reset.py:157 +#: funnel/views/account.py:693 funnel/views/account_reset.py:157 msgid "This OTP has expired" msgstr "" -#: funnel/views/account.py:696 +#: funnel/views/account.py:712 msgid "Your phone number has been verified" msgstr "" -#: funnel/views/account.py:702 +#: funnel/views/account.py:718 msgid "This phone number has already been claimed by another user" msgstr "" -#: funnel/views/account.py:708 +#: funnel/views/account.py:724 msgid "Verify phone number" msgstr "" -#: funnel/views/account.py:710 +#: funnel/views/account.py:726 msgid "Verify" msgstr "" -#: funnel/views/account.py:728 +#: funnel/views/account.py:745 msgid "Remove phone number {phone} from your account?" msgstr "" -#: funnel/views/account.py:731 +#: funnel/views/account.py:748 msgid "You have removed your number {phone}" msgstr "" -#: funnel/views/account.py:758 +#: funnel/views/account.py:775 msgid "Remove {service} account ‘{username}’ from your account?" msgstr "" -#: funnel/views/account.py:763 +#: funnel/views/account.py:780 msgid "You have removed the {service} account ‘{username}’" msgstr "" -#: funnel/views/account.py:784 +#: funnel/views/account.py:801 msgid "Your account has been deleted" msgstr "" -#: funnel/views/account.py:792 +#: funnel/views/account.py:810 msgid "You are about to delete your account permanently" msgstr "" @@ -4668,186 +4903,187 @@ msgstr "" msgid "Send OTP" msgstr "" -#: funnel/views/account_reset.py:142 funnel/views/account_reset.py:219 +#: funnel/views/account_reset.py:142 funnel/views/account_reset.py:221 msgid "" "This password reset link is invalid. If you still need to reset your " "password, you may request an OTP" msgstr "" -#: funnel/views/account_reset.py:175 +#: funnel/views/account_reset.py:177 msgid "Verify OTP" msgstr "" -#: funnel/views/account_reset.py:196 +#: funnel/views/account_reset.py:198 msgid "This page has timed out" msgstr "" -#: funnel/views/account_reset.py:197 +#: funnel/views/account_reset.py:199 msgid "Open the reset link again to reset your password" msgstr "" -#: funnel/views/account_reset.py:208 +#: funnel/views/account_reset.py:210 msgid "" "This password reset link has expired. If you still need to reset your " "password, you may request an OTP" msgstr "" -#: funnel/views/account_reset.py:235 funnel/views/api/oauth.py:473 +#: funnel/views/account_reset.py:237 funnel/views/api/oauth.py:473 msgid "Unknown user" msgstr "" -#: funnel/views/account_reset.py:236 +#: funnel/views/account_reset.py:238 msgid "There is no account matching this password reset request" msgstr "" -#: funnel/views/account_reset.py:244 +#: funnel/views/account_reset.py:246 msgid "" "This password reset link has been used. If you need to reset your " "password again, you may request an OTP" msgstr "" -#: funnel/views/account_reset.py:270 +#: funnel/views/account_reset.py:272 msgid "Password reset complete" msgstr "" -#: funnel/views/account_reset.py:271 +#: funnel/views/account_reset.py:273 msgid "Your password has been changed. You may now login with your new password" msgstr "" -#: funnel/views/account_reset.py:276 +#: funnel/views/account_reset.py:278 msgid "" "Your password has been changed. As a precaution, you have been logged out" " of one other device. You may now login with your new password" msgstr "" -#: funnel/views/account_reset.py:292 +#: funnel/views/account_reset.py:294 msgid "Hello, {fullname}. You may now choose a new password" msgstr "" -#: funnel/views/auth_client.py:93 +#: funnel/views/auth_client.py:94 msgid "Register a new client application" msgstr "" -#: funnel/views/auth_client.py:95 +#: funnel/views/auth_client.py:96 msgid "Register application" msgstr "" -#: funnel/views/auth_client.py:146 +#: funnel/views/auth_client.py:147 msgid "" "This application’s owner has changed, so all previously assigned " "permissions have been revoked" msgstr "" -#: funnel/views/auth_client.py:160 +#: funnel/views/auth_client.py:161 msgid "Edit application" msgstr "" -#: funnel/views/auth_client.py:173 funnel/views/auth_client.py:332 -#: funnel/views/auth_client.py:408 funnel/views/auth_client.py:483 -#: funnel/views/label.py:254 funnel/views/organization.py:115 -#: funnel/views/organization.py:204 funnel/views/project.py:415 -#: funnel/views/proposal.py:306 funnel/views/ticket_event.py:200 -#: funnel/views/ticket_event.py:288 funnel/views/ticket_event.py:349 -#: funnel/views/update.py:176 +#: funnel/views/auth_client.py:174 funnel/views/auth_client.py:333 +#: funnel/views/auth_client.py:409 funnel/views/auth_client.py:484 +#: funnel/views/label.py:255 funnel/views/organization.py:115 +#: funnel/views/organization.py:204 funnel/views/project.py:429 +#: funnel/views/proposal.py:318 funnel/views/ticket_event.py:199 +#: funnel/views/ticket_event.py:288 funnel/views/ticket_event.py:350 +#: funnel/views/update.py:176 funnel/views/venue.py:133 +#: funnel/views/venue.py:196 msgid "Confirm delete" msgstr "" -#: funnel/views/auth_client.py:174 +#: funnel/views/auth_client.py:175 msgid "" "Delete application ‘{title}’? This will also delete all associated " "content including access tokens issued on behalf of users. This operation" " is permanent and cannot be undone" msgstr "" -#: funnel/views/auth_client.py:179 +#: funnel/views/auth_client.py:180 msgid "" "You have deleted application ‘{title}’ and all its associated resources " "and permission assignments" msgstr "" -#: funnel/views/auth_client.py:196 +#: funnel/views/auth_client.py:197 msgid "Disconnect {app}" msgstr "" -#: funnel/views/auth_client.py:197 +#: funnel/views/auth_client.py:198 msgid "" "Disconnect application {app}? This will not remove any of your data in " "this app, but will prevent it from accessing any further data from your " "Hasgeek account" msgstr "" -#: funnel/views/auth_client.py:203 +#: funnel/views/auth_client.py:204 msgid "You have disconnected {app} from your account" msgstr "" -#: funnel/views/auth_client.py:215 +#: funnel/views/auth_client.py:216 msgid "Default" msgstr "" -#: funnel/views/auth_client.py:230 funnel/views/organization.py:147 +#: funnel/views/auth_client.py:231 funnel/views/organization.py:147 #: funnel/views/venue.py:158 msgid "Create" msgstr "" -#: funnel/views/auth_client.py:277 +#: funnel/views/auth_client.py:278 msgid "Permissions have been assigned to user {pname}" msgstr "" -#: funnel/views/auth_client.py:284 +#: funnel/views/auth_client.py:285 msgid "Permissions have been assigned to team ‘{pname}’" msgstr "" -#: funnel/views/auth_client.py:292 funnel/views/auth_client.py:302 +#: funnel/views/auth_client.py:293 funnel/views/auth_client.py:303 msgid "Assign permissions" msgstr "" -#: funnel/views/auth_client.py:294 +#: funnel/views/auth_client.py:295 msgid "" "Add and edit teams from your organization’s teams " "page" msgstr "" -#: funnel/views/auth_client.py:333 +#: funnel/views/auth_client.py:334 msgid "Delete access key ‘{title}’? " msgstr "" -#: funnel/views/auth_client.py:334 +#: funnel/views/auth_client.py:335 msgid "You have deleted access key ‘{title}’" msgstr "" -#: funnel/views/auth_client.py:380 +#: funnel/views/auth_client.py:381 msgid "Permissions have been updated for user {pname}" msgstr "" -#: funnel/views/auth_client.py:387 +#: funnel/views/auth_client.py:388 msgid "All permissions have been revoked for user {pname}" msgstr "" -#: funnel/views/auth_client.py:395 funnel/views/auth_client.py:470 +#: funnel/views/auth_client.py:396 funnel/views/auth_client.py:471 msgid "Edit permissions" msgstr "" -#: funnel/views/auth_client.py:409 +#: funnel/views/auth_client.py:410 msgid "Remove all permissions assigned to user {pname} for app ‘{title}’?" msgstr "" -#: funnel/views/auth_client.py:412 +#: funnel/views/auth_client.py:413 msgid "You have revoked permisions for user {pname}" msgstr "" -#: funnel/views/auth_client.py:455 +#: funnel/views/auth_client.py:456 msgid "Permissions have been updated for team {title}" msgstr "" -#: funnel/views/auth_client.py:462 +#: funnel/views/auth_client.py:463 msgid "All permissions have been revoked for team {title}" msgstr "" -#: funnel/views/auth_client.py:484 +#: funnel/views/auth_client.py:485 msgid "Remove all permissions assigned to team ‘{pname}’ for app ‘{title}’?" msgstr "" -#: funnel/views/auth_client.py:487 +#: funnel/views/auth_client.py:488 msgid "You have revoked permisions for team {title}" msgstr "" @@ -4875,7 +5111,7 @@ msgstr "" msgid "Request expired. Reload and try again" msgstr "" -#: funnel/views/comment.py:265 funnel/views/project.py:698 +#: funnel/views/comment.py:265 funnel/views/project.py:718 msgid "This page timed out. Reload and try again" msgstr "" @@ -4915,30 +5151,18 @@ msgstr "" msgid "Unauthorized contact exchange" msgstr "" -#: funnel/views/email.py:16 +#: funnel/views/email.py:15 msgid "Verify your email address" msgstr "" -#: funnel/views/email.py:25 +#: funnel/views/email.py:24 msgid "Verify email address" msgstr "" -#: funnel/views/email.py:37 funnel/views/otp.py:456 +#: funnel/views/email.py:36 funnel/views/otp.py:475 msgid "Reset your password - OTP {otp}" msgstr "" -#: funnel/views/email.py:61 -msgid "You have been added to {project} as a crew member" -msgstr "" - -#: funnel/views/email.py:79 -msgid "You have been invited to {project} as a crew member" -msgstr "" - -#: funnel/views/email.py:97 -msgid "You have been removed from {project} as a crew member" -msgstr "" - #: funnel/views/index.py:29 msgid "Terms of service" msgstr "" @@ -4965,23 +5189,23 @@ msgstr "" #: funnel/views/label.py:40 funnel/views/profile.py:281 #: funnel/views/profile.py:313 funnel/views/profile.py:360 -#: funnel/views/profile.py:398 funnel/views/project.py:380 -#: funnel/views/project.py:451 funnel/views/project.py:492 -#: funnel/views/project.py:517 funnel/views/proposal.py:234 -#: funnel/views/ticket_event.py:189 funnel/views/ticket_event.py:275 -#: funnel/views/ticket_event.py:336 funnel/views/ticket_participant.py:208 +#: funnel/views/profile.py:398 funnel/views/project.py:394 +#: funnel/views/project.py:465 funnel/views/project.py:506 +#: funnel/views/project.py:537 funnel/views/proposal.py:235 +#: funnel/views/ticket_event.py:188 funnel/views/ticket_event.py:275 +#: funnel/views/ticket_event.py:337 funnel/views/ticket_participant.py:208 msgid "Your changes have been saved" msgstr "" -#: funnel/views/label.py:80 funnel/views/label.py:176 +#: funnel/views/label.py:81 funnel/views/label.py:176 msgid "Error with a label option: {}" msgstr "" -#: funnel/views/label.py:83 funnel/views/label.py:93 +#: funnel/views/label.py:84 funnel/views/label.py:94 msgid "Add label" msgstr "" -#: funnel/views/label.py:145 +#: funnel/views/label.py:144 msgid "Only main labels can be edited" msgstr "" @@ -5009,11 +5233,11 @@ msgstr "" msgid "Labels that have been assigned to submissions cannot be deleted" msgstr "" -#: funnel/views/label.py:250 +#: funnel/views/label.py:251 msgid "The label has been deleted" msgstr "" -#: funnel/views/label.py:255 +#: funnel/views/label.py:256 msgid "Delete this label? This operation is permanent and cannot be undone" msgstr "" @@ -5021,80 +5245,80 @@ msgstr "" msgid "Are you trying to logout? Try again to confirm" msgstr "" -#: funnel/views/login.py:182 +#: funnel/views/login.py:187 msgid "" "You have a weak password. To ensure the safety of your account, please " "choose a stronger password" msgstr "" -#: funnel/views/login.py:200 +#: funnel/views/login.py:205 msgid "" "Your password is a year old. To ensure the safety of your account, please" " choose a new password" msgstr "" -#: funnel/views/login.py:215 funnel/views/login.py:291 -#: funnel/views/login.py:739 +#: funnel/views/login.py:220 funnel/views/login.py:299 +#: funnel/views/login.py:751 msgid "You are now logged in" msgstr "" -#: funnel/views/login.py:222 +#: funnel/views/login.py:227 msgid "" "Your account does not have a password. Please enter your phone number or " "email address to request an OTP and set a new password" msgstr "" -#: funnel/views/login.py:232 +#: funnel/views/login.py:237 msgid "" "Your account has a weak password. Please enter your phone number or email" " address to request an OTP and set a new password" msgstr "" -#: funnel/views/login.py:281 +#: funnel/views/login.py:289 msgid "You are now one of us. Welcome aboard!" msgstr "" -#: funnel/views/login.py:301 +#: funnel/views/login.py:311 msgid "The OTP has expired. Try again?" msgstr "" -#: funnel/views/login.py:364 +#: funnel/views/login.py:375 msgid "To logout, use the logout button" msgstr "" -#: funnel/views/login.py:384 funnel/views/login.py:769 +#: funnel/views/login.py:395 funnel/views/login.py:781 msgid "You are now logged out" msgstr "" -#: funnel/views/login.py:428 +#: funnel/views/login.py:439 msgid "{service} login failed: {error}" msgstr "" -#: funnel/views/login.py:570 +#: funnel/views/login.py:581 msgid "You have logged in via {service}" msgstr "" -#: funnel/views/login.py:614 +#: funnel/views/login.py:625 msgid "Your accounts have been merged" msgstr "" -#: funnel/views/login.py:619 +#: funnel/views/login.py:630 msgid "Account merger failed" msgstr "" -#: funnel/views/login.py:674 +#: funnel/views/login.py:686 msgid "Cookies required" msgstr "" -#: funnel/views/login.py:675 +#: funnel/views/login.py:687 msgid "Please enable cookies in your browser" msgstr "" -#: funnel/views/login.py:724 +#: funnel/views/login.py:736 msgid "Your attempt to login failed. Try again?" msgstr "" -#: funnel/views/login.py:730 +#: funnel/views/login.py:742 msgid "Are you trying to login? Try again to confirm" msgstr "" @@ -5118,147 +5342,151 @@ msgstr "" msgid "Your account is not active" msgstr "" -#: funnel/views/login_session.py:460 funnel/views/login_session.py:487 -#: funnel/views/login_session.py:560 +#: funnel/views/login_session.py:462 funnel/views/login_session.py:489 +#: funnel/views/login_session.py:562 msgid "You need to be logged in for that page" msgstr "" -#: funnel/views/login_session.py:466 +#: funnel/views/login_session.py:468 msgid "Confirm your phone number to continue" msgstr "" -#: funnel/views/login_session.py:579 +#: funnel/views/login_session.py:581 msgid "This request requires re-authentication" msgstr "" -#: funnel/views/login_session.py:635 +#: funnel/views/login_session.py:637 msgid "" "This operation requires you to confirm your password. However, your " "account does not have a password, so you must set one first" msgstr "" -#: funnel/views/login_session.py:677 +#: funnel/views/login_session.py:681 msgid "Confirm this operation with an OTP" msgstr "" -#: funnel/views/login_session.py:680 +#: funnel/views/login_session.py:684 msgid "Confirm with your password to proceed" msgstr "" -#: funnel/views/membership.py:80 funnel/views/membership.py:337 +#: funnel/views/membership.py:82 funnel/views/membership.py:329 msgid "" "This user does not have any verified contact information. If you are able" " to contact them, please ask them to verify their email address or phone " "number" msgstr "" -#: funnel/views/membership.py:106 +#: funnel/views/membership.py:108 msgid "This user is already an admin" msgstr "" -#: funnel/views/membership.py:126 +#: funnel/views/membership.py:128 msgid "The user has been added as an admin" msgstr "" -#: funnel/views/membership.py:137 +#: funnel/views/membership.py:139 msgid "The new admin could not be added" msgstr "" -#: funnel/views/membership.py:187 +#: funnel/views/membership.py:189 msgid "You can’t edit your own role" msgstr "" -#: funnel/views/membership.py:199 +#: funnel/views/membership.py:200 msgid "This member’s record was edited elsewhere. Reload the page" msgstr "" -#: funnel/views/membership.py:218 funnel/views/membership.py:515 +#: funnel/views/membership.py:217 funnel/views/membership.py:513 msgid "The member’s roles have been updated" msgstr "" -#: funnel/views/membership.py:220 +#: funnel/views/membership.py:219 msgid "No changes were detected" msgstr "" -#: funnel/views/membership.py:232 funnel/views/membership.py:394 -#: funnel/views/membership.py:526 +#: funnel/views/membership.py:230 funnel/views/membership.py:375 +#: funnel/views/membership.py:523 msgid "Please pick one or more roles" msgstr "" -#: funnel/views/membership.py:259 +#: funnel/views/membership.py:255 msgid "You can’t revoke your own membership" msgstr "" -#: funnel/views/membership.py:273 funnel/views/membership.py:566 +#: funnel/views/membership.py:269 funnel/views/membership.py:559 msgid "The member has been removed" msgstr "" -#: funnel/views/membership.py:293 +#: funnel/views/membership.py:286 msgid "Remove {member} as an admin from {account}?" msgstr "" -#: funnel/views/membership.py:357 +#: funnel/views/membership.py:345 msgid "This person is already a member" msgstr "" -#: funnel/views/membership.py:383 +#: funnel/views/membership.py:365 msgid "The user has been added as a member" msgstr "" -#: funnel/views/membership.py:500 +#: funnel/views/membership.py:449 +msgid "This is not a valid response" +msgstr "" + +#: funnel/views/membership.py:495 msgid "The member’s record was edited elsewhere. Reload the page" msgstr "" -#: funnel/views/membership.py:579 +#: funnel/views/membership.py:572 msgid "Remove {member} as a crew member from this project?" msgstr "" -#: funnel/views/mixins.py:242 +#: funnel/views/mixins.py:238 msgid "There is no draft for the given object" msgstr "" -#: funnel/views/mixins.py:267 +#: funnel/views/mixins.py:263 msgid "Form must contain a revision ID" msgstr "" -#: funnel/views/mixins.py:290 +#: funnel/views/mixins.py:286 msgid "" "Invalid revision ID or the existing changes have been submitted already. " "Please reload" msgstr "" -#: funnel/views/mixins.py:308 +#: funnel/views/mixins.py:304 msgid "" "There have been changes to this draft since you last edited it. Please " "reload" msgstr "" -#: funnel/views/mixins.py:348 +#: funnel/views/mixins.py:344 msgid "Invalid CSRF token" msgstr "" -#: funnel/views/notification.py:66 +#: funnel/views/notification.py:142 msgid "You are receiving this because you have an account at hasgeek.com" msgstr "" -#: funnel/views/notification_preferences.py:44 +#: funnel/views/notification_preferences.py:45 msgid "" "That unsubscribe link has expired. However, you can manage your " "preferences from your account page" msgstr "" -#: funnel/views/notification_preferences.py:49 +#: funnel/views/notification_preferences.py:50 msgid "" "That unsubscribe link is invalid. However, you can manage your " "preferences from your account page" msgstr "" -#: funnel/views/notification_preferences.py:205 +#: funnel/views/notification_preferences.py:206 #: funnel/views/notification_preferences.py:397 msgid "This unsubscribe link is for a non-existent user" msgstr "" -#: funnel/views/notification_preferences.py:223 +#: funnel/views/notification_preferences.py:224 msgid "You have been unsubscribed from this notification type" msgstr "" @@ -5276,19 +5504,19 @@ msgstr "" msgid "Unknown user account" msgstr "" -#: funnel/views/notification_preferences.py:430 +#: funnel/views/notification_preferences.py:441 msgid "Preferences saved" msgstr "" -#: funnel/views/notification_preferences.py:431 +#: funnel/views/notification_preferences.py:442 msgid "Your notification preferences have been updated" msgstr "" -#: funnel/views/notification_preferences.py:435 +#: funnel/views/notification_preferences.py:446 msgid "Notification preferences" msgstr "" -#: funnel/views/notification_preferences.py:437 +#: funnel/views/notification_preferences.py:448 msgid "Save preferences" msgstr "" @@ -5341,45 +5569,45 @@ msgstr "" msgid "You have deleted team ‘{team}’ from organization ‘{org}’" msgstr "" -#: funnel/views/otp.py:253 +#: funnel/views/otp.py:252 msgid "Unable to send an OTP to your phone number {number} right now" msgstr "" -#: funnel/views/otp.py:264 funnel/views/otp.py:365 +#: funnel/views/otp.py:263 funnel/views/otp.py:382 msgid "An OTP has been sent to your phone number {number}" msgstr "" -#: funnel/views/otp.py:327 +#: funnel/views/otp.py:344 msgid "Your phone number {number} is not supported for SMS. Use password to login" msgstr "" -#: funnel/views/otp.py:335 +#: funnel/views/otp.py:352 msgid "" "Your phone number {number} is not supported for SMS. Use an email address" " to register" msgstr "" -#: funnel/views/otp.py:345 +#: funnel/views/otp.py:362 msgid "" "Unable to send an OTP to your phone number {number} right now. Use " "password to login, or try again later" msgstr "" -#: funnel/views/otp.py:353 +#: funnel/views/otp.py:370 msgid "" "Unable to send an OTP to your phone number {number} right now. Use an " "email address to register, or try again later" msgstr "" -#: funnel/views/otp.py:383 +#: funnel/views/otp.py:400 msgid "Login OTP {otp}" msgstr "" -#: funnel/views/otp.py:391 funnel/views/otp.py:431 funnel/views/otp.py:474 +#: funnel/views/otp.py:408 funnel/views/otp.py:450 funnel/views/otp.py:493 msgid "An OTP has been sent to your email address {email}" msgstr "" -#: funnel/views/otp.py:423 +#: funnel/views/otp.py:442 msgid "Confirmation OTP {otp}" msgstr "" @@ -5396,11 +5624,11 @@ msgid "Were you trying to remove the logo? Try again to confirm" msgstr "" #: funnel/views/profile.py:362 funnel/views/profile.py:366 -#: funnel/views/project.py:453 funnel/views/project.py:457 +#: funnel/views/project.py:467 funnel/views/project.py:471 msgid "Save banner" msgstr "" -#: funnel/views/profile.py:384 funnel/views/project.py:476 +#: funnel/views/profile.py:384 funnel/views/project.py:490 msgid "Were you trying to remove the banner? Try again to confirm" msgstr "" @@ -5408,303 +5636,307 @@ msgstr "" msgid "There was a problem saving your changes. Please try again" msgstr "" -#: funnel/views/project.py:71 +#: funnel/views/project.py:72 msgid "Be the first to register!" msgstr "" -#: funnel/views/project.py:71 +#: funnel/views/project.py:72 msgid "Be the first follower!" msgstr "" -#: funnel/views/project.py:73 +#: funnel/views/project.py:74 msgid "One registration so far" msgstr "" -#: funnel/views/project.py:74 +#: funnel/views/project.py:75 msgid "You have registered" msgstr "" -#: funnel/views/project.py:75 +#: funnel/views/project.py:76 msgid "One follower so far" msgstr "" -#: funnel/views/project.py:76 +#: funnel/views/project.py:77 msgid "You are following this" msgstr "" -#: funnel/views/project.py:79 -msgid "Two registrations so far" -msgstr "" - #: funnel/views/project.py:80 -msgid "You and one other have registered" +msgid "Two registrations so far" msgstr "" #: funnel/views/project.py:81 -msgid "Two followers so far" +msgid "You & one other have registered" msgstr "" #: funnel/views/project.py:82 -msgid "You and one other are following" +msgid "Two followers so far" msgstr "" -#: funnel/views/project.py:85 -msgid "Three registrations so far" +#: funnel/views/project.py:83 +msgid "You & one other are following" msgstr "" #: funnel/views/project.py:86 -msgid "You and two others have registered" +msgid "Three registrations so far" msgstr "" #: funnel/views/project.py:87 -msgid "Three followers so far" +msgid "You & two others have registered" msgstr "" #: funnel/views/project.py:88 -msgid "You and two others are following" +msgid "Three followers so far" msgstr "" -#: funnel/views/project.py:91 -msgid "Four registrations so far" +#: funnel/views/project.py:89 +msgid "You & two others are following" msgstr "" #: funnel/views/project.py:92 -msgid "You and three others have registered" +msgid "Four registrations so far" msgstr "" #: funnel/views/project.py:93 -msgid "Four followers so far" +msgid "You & three others have registered" msgstr "" #: funnel/views/project.py:94 -msgid "You and three others are following" +msgid "Four followers so far" msgstr "" -#: funnel/views/project.py:97 -msgid "Five registrations so far" +#: funnel/views/project.py:95 +msgid "You & three others are following" msgstr "" #: funnel/views/project.py:98 -msgid "You and four others have registered" +msgid "Five registrations so far" msgstr "" #: funnel/views/project.py:99 -msgid "Five followers so far" +msgid "You & four others have registered" msgstr "" #: funnel/views/project.py:100 -msgid "You and four others are following" +msgid "Five followers so far" msgstr "" -#: funnel/views/project.py:103 -msgid "Six registrations so far" +#: funnel/views/project.py:101 +msgid "You & four others are following" msgstr "" #: funnel/views/project.py:104 -msgid "You and five others have registered" +msgid "Six registrations so far" msgstr "" #: funnel/views/project.py:105 -msgid "Six followers so far" +msgid "You & five others have registered" msgstr "" #: funnel/views/project.py:106 -msgid "You and five others are following" +msgid "Six followers so far" msgstr "" -#: funnel/views/project.py:109 -msgid "Seven registrations so far" +#: funnel/views/project.py:107 +msgid "You & five others are following" msgstr "" #: funnel/views/project.py:110 -msgid "You and six others have registered" +msgid "Seven registrations so far" msgstr "" #: funnel/views/project.py:111 -msgid "Seven followers so far" +msgid "You & six others have registered" msgstr "" #: funnel/views/project.py:112 -msgid "You and six others are following" +msgid "Seven followers so far" msgstr "" -#: funnel/views/project.py:115 -msgid "Eight registrations so far" +#: funnel/views/project.py:113 +msgid "You & six others are following" msgstr "" #: funnel/views/project.py:116 -msgid "You and seven others have registered" +msgid "Eight registrations so far" msgstr "" #: funnel/views/project.py:117 -msgid "Eight followers so far" +msgid "You & seven others have registered" msgstr "" #: funnel/views/project.py:118 -msgid "You and seven others are following" +msgid "Eight followers so far" msgstr "" -#: funnel/views/project.py:121 -msgid "Nine registrations so far" +#: funnel/views/project.py:119 +msgid "You & seven others are following" msgstr "" #: funnel/views/project.py:122 -msgid "You and eight others have registered" +msgid "Nine registrations so far" msgstr "" #: funnel/views/project.py:123 -msgid "Nine followers so far" +msgid "You & eight others have registered" msgstr "" #: funnel/views/project.py:124 -msgid "You and eight others are following" +msgid "Nine followers so far" msgstr "" -#: funnel/views/project.py:127 -msgid "Ten registrations so far" +#: funnel/views/project.py:125 +msgid "You & eight others are following" msgstr "" #: funnel/views/project.py:128 -msgid "You and nine others have registered" +msgid "Ten registrations so far" msgstr "" #: funnel/views/project.py:129 -msgid "Ten followers so far" +msgid "You & nine others have registered" msgstr "" #: funnel/views/project.py:130 -msgid "You and nine others are following" +msgid "Ten followers so far" msgstr "" -#: funnel/views/project.py:134 -msgid "{num} registrations so far" +#: funnel/views/project.py:131 +msgid "You & nine others are following" msgstr "" #: funnel/views/project.py:135 -msgid "You and {num} others have registered" +msgid "{num} registrations so far" msgstr "" #: funnel/views/project.py:136 -msgid "{num} followers so far" +msgid "You & {num} others have registered" msgstr "" #: funnel/views/project.py:137 -msgid "You and {num} others are following" +msgid "{num} followers so far" +msgstr "" + +#: funnel/views/project.py:138 +msgid "You & {num} others are following" msgstr "" -#: funnel/views/project.py:228 +#: funnel/views/project.py:240 msgid "Follow" msgstr "" -#: funnel/views/project.py:254 +#: funnel/views/project.py:268 msgid "Your new project has been created" msgstr "" -#: funnel/views/project.py:263 +#: funnel/views/project.py:277 msgid "Create project" msgstr "" -#: funnel/views/project.py:329 +#: funnel/views/project.py:343 msgid "Customize the URL" msgstr "" -#: funnel/views/project.py:342 +#: funnel/views/project.py:356 msgid "Add or edit livestream URLs" msgstr "" -#: funnel/views/project.py:369 funnel/views/project.py:393 +#: funnel/views/project.py:383 funnel/views/project.py:407 msgid "Edit project" msgstr "" -#: funnel/views/project.py:406 +#: funnel/views/project.py:420 msgid "This project has submissions" msgstr "" -#: funnel/views/project.py:407 +#: funnel/views/project.py:421 msgid "" "Submissions must be deleted or transferred before the project can be " "deleted" msgstr "" -#: funnel/views/project.py:416 +#: funnel/views/project.py:430 msgid "" "Delete project ‘{title}’? This will delete everything in the project. " "This operation is permanent and cannot be undone" msgstr "" -#: funnel/views/project.py:420 +#: funnel/views/project.py:434 msgid "You have deleted project ‘{title}’ and all its associated content" msgstr "" -#: funnel/views/project.py:522 +#: funnel/views/project.py:542 msgid "Edit ticket client details" msgstr "" -#: funnel/views/project.py:542 +#: funnel/views/project.py:562 msgid "Invalid transition for this project" msgstr "" -#: funnel/views/project.py:558 +#: funnel/views/project.py:578 msgid "This project can now receive submissions" msgstr "" -#: funnel/views/project.py:562 +#: funnel/views/project.py:582 msgid "This project will no longer accept submissions" msgstr "" -#: funnel/views/project.py:567 +#: funnel/views/project.py:587 msgid "Invalid form submission" msgstr "" -#: funnel/views/project.py:588 +#: funnel/views/project.py:608 msgid "Were you trying to register? Try again to confirm" msgstr "" -#: funnel/views/project.py:610 +#: funnel/views/project.py:630 msgid "Were you trying to cancel your registration? Try again to confirm" msgstr "" -#: funnel/views/project.py:720 funnel/views/ticket_event.py:150 +#: funnel/views/project.py:740 funnel/views/ticket_event.py:149 msgid "Importing tickets from vendors… Reload the page in about 30 seconds…" msgstr "" -#: funnel/views/project.py:778 +#: funnel/views/project.py:798 msgid "This project has been featured" msgstr "" -#: funnel/views/project.py:781 +#: funnel/views/project.py:801 msgid "This project is no longer featured" msgstr "" -#: funnel/views/project_sponsor.py:54 +#: funnel/views/project_sponsor.py:56 msgid "{sponsor} is already a sponsor" msgstr "" -#: funnel/views/project_sponsor.py:69 +#: funnel/views/project_sponsor.py:71 msgid "Sponsor has been added" msgstr "" -#: funnel/views/project_sponsor.py:74 +#: funnel/views/project_sponsor.py:76 msgid "Sponsor could not be added" msgstr "" -#: funnel/views/project_sponsor.py:127 +#: funnel/views/project_sponsor.py:159 msgid "Sponsor has been edited" msgstr "" -#: funnel/views/project_sponsor.py:134 +#: funnel/views/project_sponsor.py:166 msgid "Sponsor could not be edited" msgstr "" -#: funnel/views/project_sponsor.py:156 +#: funnel/views/project_sponsor.py:188 msgid "Sponsor has been removed" msgstr "" -#: funnel/views/project_sponsor.py:162 +#: funnel/views/project_sponsor.py:194 msgid "Sponsor could not be removed" msgstr "" -#: funnel/views/project_sponsor.py:173 +#: funnel/views/project_sponsor.py:204 +msgid "Remove sponsor?" +msgstr "" + +#: funnel/views/project_sponsor.py:205 msgid "Remove ‘{sponsor}’ as a sponsor?" msgstr "" @@ -5723,157 +5955,161 @@ msgstr "" msgid "New submission" msgstr "" -#: funnel/views/proposal.py:260 +#: funnel/views/proposal.py:262 msgid "{user} has been added as an collaborator" msgstr "" -#: funnel/views/proposal.py:307 +#: funnel/views/proposal.py:276 +msgid "Pick a user to be added" +msgstr "" + +#: funnel/views/proposal.py:319 msgid "" "Delete your submission ‘{title}’? This will remove all comments as well. " "This operation is permanent and cannot be undone" msgstr "" -#: funnel/views/proposal.py:311 +#: funnel/views/proposal.py:323 msgid "Your submission has been deleted" msgstr "" -#: funnel/views/proposal.py:334 +#: funnel/views/proposal.py:346 msgid "Invalid transition for this submission" msgstr "" -#: funnel/views/proposal.py:349 +#: funnel/views/proposal.py:361 msgid "This submission has been moved to {project}" msgstr "" -#: funnel/views/proposal.py:356 +#: funnel/views/proposal.py:368 msgid "Please choose the project you want to move this submission to" msgstr "" -#: funnel/views/proposal.py:372 +#: funnel/views/proposal.py:384 msgid "This submission has been featured" msgstr "" -#: funnel/views/proposal.py:376 +#: funnel/views/proposal.py:388 msgid "This submission is no longer featured" msgstr "" -#: funnel/views/proposal.py:399 +#: funnel/views/proposal.py:411 msgid "Labels have been saved for this submission" msgstr "" -#: funnel/views/proposal.py:401 +#: funnel/views/proposal.py:413 msgid "Labels could not be saved for this submission" msgstr "" -#: funnel/views/proposal.py:405 +#: funnel/views/proposal.py:417 msgid "Edit labels for '{}'" msgstr "" -#: funnel/views/proposal.py:467 +#: funnel/views/proposal.py:479 msgid "{user}’s role has been updated" msgstr "" -#: funnel/views/proposal.py:496 +#: funnel/views/proposal.py:509 msgid "The sole collaborator on a submission cannot be removed" msgstr "" -#: funnel/views/proposal.py:504 +#: funnel/views/proposal.py:517 msgid "{user} is no longer a collaborator" msgstr "" -#: funnel/views/schedule.py:217 +#: funnel/views/schedule.py:222 msgid "{session} in {venue} in 5 minutes" msgstr "" -#: funnel/views/schedule.py:221 +#: funnel/views/schedule.py:226 msgid "{session} in 5 minutes" msgstr "" -#: funnel/views/search.py:314 +#: funnel/views/search.py:320 msgid "Accounts" msgstr "" -#: funnel/views/session.py:34 +#: funnel/views/session.py:27 msgid "Select Room" msgstr "" -#: funnel/views/session.py:217 +#: funnel/views/session.py:215 msgid "This project will not be listed as it has no sessions in the schedule" msgstr "" -#: funnel/views/session.py:251 +#: funnel/views/session.py:249 msgid "Something went wrong, please reload and try again" msgstr "" -#: funnel/views/siteadmin.py:285 +#: funnel/views/siteadmin.py:326 msgid "Comment(s) successfully reported as spam" msgstr "" -#: funnel/views/siteadmin.py:288 +#: funnel/views/siteadmin.py:329 msgid "There was a problem marking the comments as spam. Try again?" msgstr "" -#: funnel/views/siteadmin.py:303 +#: funnel/views/siteadmin.py:344 msgid "There are no comment reports to review at this time" msgstr "" -#: funnel/views/siteadmin.py:320 +#: funnel/views/siteadmin.py:361 msgid "You cannot review same comment twice" msgstr "" -#: funnel/views/siteadmin.py:324 +#: funnel/views/siteadmin.py:365 msgid "You cannot review your own report" msgstr "" -#: funnel/views/siteadmin.py:336 +#: funnel/views/siteadmin.py:377 msgid "This comment has already been marked as spam" msgstr "" -#: funnel/views/ticket_event.py:80 +#: funnel/views/ticket_event.py:79 msgid "This event already exists" msgstr "" -#: funnel/views/ticket_event.py:82 +#: funnel/views/ticket_event.py:81 msgid "New Event" msgstr "" -#: funnel/views/ticket_event.py:82 +#: funnel/views/ticket_event.py:81 msgid "Add event" msgstr "" -#: funnel/views/ticket_event.py:99 +#: funnel/views/ticket_event.py:98 msgid "This ticket type already exists" msgstr "" -#: funnel/views/ticket_event.py:102 +#: funnel/views/ticket_event.py:101 msgid "New Ticket Type" msgstr "" -#: funnel/views/ticket_event.py:102 +#: funnel/views/ticket_event.py:101 msgid "Add ticket type" msgstr "" -#: funnel/views/ticket_event.py:118 +#: funnel/views/ticket_event.py:117 msgid "This ticket client already exists" msgstr "" -#: funnel/views/ticket_event.py:121 +#: funnel/views/ticket_event.py:120 msgid "New Ticket Client" msgstr "" -#: funnel/views/ticket_event.py:121 +#: funnel/views/ticket_event.py:120 msgid "Add ticket client" msgstr "" -#: funnel/views/ticket_event.py:191 +#: funnel/views/ticket_event.py:190 msgid "Edit event" msgstr "" -#: funnel/views/ticket_event.py:201 +#: funnel/views/ticket_event.py:200 msgid "Delete event ‘{title}’? This operation is permanent and cannot be undone" msgstr "" -#: funnel/views/ticket_event.py:205 funnel/views/ticket_event.py:354 +#: funnel/views/ticket_event.py:204 funnel/views/ticket_event.py:355 msgid "This event has been deleted" msgstr "" @@ -5891,25 +6127,25 @@ msgstr "" msgid "This ticket type has been deleted" msgstr "" -#: funnel/views/ticket_event.py:339 +#: funnel/views/ticket_event.py:340 msgid "Edit ticket client" msgstr "" -#: funnel/views/ticket_event.py:350 +#: funnel/views/ticket_event.py:351 msgid "" "Delete ticket client ‘{title}’? This operation is permanent and cannot be" " undone" msgstr "" -#: funnel/views/ticket_participant.py:162 +#: funnel/views/ticket_participant.py:161 msgid "This participant already exists" msgstr "" -#: funnel/views/ticket_participant.py:165 +#: funnel/views/ticket_participant.py:164 msgid "New ticketed participant" msgstr "" -#: funnel/views/ticket_participant.py:165 +#: funnel/views/ticket_participant.py:164 msgid "Add participant" msgstr "" @@ -5917,7 +6153,7 @@ msgstr "" msgid "Edit Participant" msgstr "" -#: funnel/views/ticket_participant.py:353 +#: funnel/views/ticket_participant.py:355 msgid "Attendee not found" msgstr "" @@ -6035,6 +6271,10 @@ msgstr "" msgid "Something went wrong. Please reload and try again" msgstr "" +#: funnel/views/api/markdown.py:24 +msgid "Unknown Markdown profile: {profile}" +msgstr "" + #: funnel/views/api/oauth.py:48 msgid "Full access is only available to trusted clients" msgstr "" @@ -6127,27 +6367,27 @@ msgstr "" msgid "Read your name and basic account data" msgstr "" -#: funnel/views/api/resource.py:483 +#: funnel/views/api/resource.py:486 msgid "Verify user session" msgstr "" -#: funnel/views/api/resource.py:503 +#: funnel/views/api/resource.py:506 msgid "Read your email address" msgstr "" -#: funnel/views/api/resource.py:515 +#: funnel/views/api/resource.py:522 msgid "Read your phone number" msgstr "" -#: funnel/views/api/resource.py:529 +#: funnel/views/api/resource.py:536 msgid "Access your external account information such as Twitter and Google" msgstr "" -#: funnel/views/api/resource.py:552 +#: funnel/views/api/resource.py:559 msgid "Read the organizations you are a member of" msgstr "" -#: funnel/views/api/resource.py:567 +#: funnel/views/api/resource.py:574 msgid "Read the list of teams in your organizations" msgstr "" @@ -6209,106 +6449,464 @@ msgstr "" msgid "{actor} replied to you:" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:50 -msgid "You have been invited as an owner of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:72 +msgid "{user} was invited to be owner of {organization} by {actor}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:56 -msgid "You have been invited as an admin of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:79 +msgid "{user} was invited to be admin of {organization} by {actor}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:63 -msgid "You are now an owner of {organization}" +#: funnel/views/notifications/organization_membership_notification.py:91 +msgid "{actor} invited you to be owner of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:69 -msgid "You are now an admin of {organization}" +#: funnel/views/notifications/organization_membership_notification.py:97 +msgid "{actor} invited you to be admin of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:76 -msgid "You have changed your role to owner of {organization}" +#: funnel/views/notifications/organization_membership_notification.py:108 +msgid "You invited {user} to be owner of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:83 -msgid "You have changed your role to an admin of {organization}" +#: funnel/views/notifications/organization_membership_notification.py:114 +msgid "You invited {user} to be admin of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:91 -msgid "You were added as an owner of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:130 +msgid "{user} was made owner of {organization} by {actor}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:98 -msgid "You were added as an admin of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:136 +msgid "{user} was made admin of {organization} by {actor}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:105 -msgid "Your role was changed to owner of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:147 +msgid "{actor} made you owner of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:112 -msgid "Your role was changed to admin of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:151 +msgid "{actor} made you admin of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:121 -msgid "{user} was invited to be an owner of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:160 +msgid "You made {user} owner of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:126 -msgid "{user} was invited to be an admin of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:164 +msgid "You made {user} admin of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:132 -msgid "{user} is now an owner of {organization}" +#: funnel/views/notifications/organization_membership_notification.py:178 +#: funnel/views/notifications/organization_membership_notification.py:197 +msgid "{user} accepted an invite to be owner of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:137 -msgid "{user} is now an admin of {organization}" +#: funnel/views/notifications/organization_membership_notification.py:185 +#: funnel/views/notifications/organization_membership_notification.py:204 +msgid "{user} accepted an invite to be admin of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:143 -msgid "{user} changed their role to owner of {organization}" +#: funnel/views/notifications/organization_membership_notification.py:216 +msgid "You accepted an invite to be owner of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:149 -msgid "{user} changed their role from owner to admin of {organization}" +#: funnel/views/notifications/organization_membership_notification.py:222 +msgid "You accepted an invite to be admin of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:156 -msgid "{user} was made an owner of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:238 +msgid "{user}’s role was changed to owner of {organization} by {actor}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:161 -msgid "{user} was made an admin of {organization} by {actor}" +#: funnel/views/notifications/organization_membership_notification.py:245 +msgid "{user}’s role was changed to admin of {organization} by {actor}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:221 -#: funnel/views/notifications/organization_membership_notification.py:230 -#: funnel/views/notifications/organization_membership_notification.py:240 -msgid "(unknown)" +#: funnel/views/notifications/organization_membership_notification.py:257 +msgid "{actor} changed your role to owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:263 +msgid "{actor} changed your role to admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:274 +msgid "You changed {user}’s role to owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:280 +msgid "You changed {user}’s role to admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:299 +msgid "{user} was removed as owner of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:305 +msgid "{user} was removed as admin of {organization} by {actor}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:316 +msgid "{actor} removed you from owner of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:320 +msgid "{actor} removed you from admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:329 +msgid "You removed {user} from owner of {organization}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:250 +#: funnel/views/notifications/organization_membership_notification.py:333 +msgid "You removed {user} from admin of {organization}" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:343 +#: funnel/views/notifications/organization_membership_notification.py:444 msgid "You are receiving this because you are an admin of this organization" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:300 +#: funnel/views/notifications/organization_membership_notification.py:415 +#: funnel/views/notifications/organization_membership_notification.py:424 +#: funnel/views/notifications/organization_membership_notification.py:434 +#: funnel/views/notifications/project_crew_notification.py:743 +#: funnel/views/notifications/project_crew_notification.py:752 +#: funnel/views/notifications/project_crew_notification.py:762 +msgid "(unknown)" +msgstr "" + +#: funnel/views/notifications/organization_membership_notification.py:475 msgid "You are receiving this because you were an admin of this organization" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:319 -msgid "You removed yourself as an admin of {organization}" +#: funnel/views/notifications/project_crew_notification.py:85 +msgid "{user} was invited to be editor and promoter of {project} by {actor}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:320 -msgid "You were removed as an admin of {organization} by {actor}" +#: funnel/views/notifications/project_crew_notification.py:93 +msgid "{user} was invited to be editor of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:100 +msgid "{user} was invited to be promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:107 +msgid "{user} was invited to join the crew of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:119 +msgid "{actor} invited you to be editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:127 +msgid "{actor} invited you to be editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:133 +msgid "{actor} invited you to be promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:139 +msgid "{actor} invited you to join the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:152 +msgid "You invited {user} to be editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:160 +msgid "You invited {user} to be editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:164 +msgid "You invited {user} to be promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:170 +msgid "You invited {user} to join the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:188 +msgid "{user} accepted an invite to be editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:196 +msgid "{user} accepted an invite to be editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:202 +msgid "{user} accepted an invite to be promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:208 +msgid "{user} accepted an invite to join the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:220 +msgid "You accepted an invite to be editor and promoter of {project}" msgstr "" -#: funnel/views/notifications/organization_membership_notification.py:321 -msgid "{user} was removed as an admin of {organization} by {actor}" +#: funnel/views/notifications/project_crew_notification.py:228 +msgid "You accepted an invite to be promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:234 +msgid "You accepted an invite to be editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:240 +msgid "You accepted an invite to join the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:256 +msgid "{actor} joined {project} as editor and promoter" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:264 +msgid "{actor} joined {project} as editor" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:269 +msgid "{actor} joined {project} as promoter" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:274 +msgid "{actor} joined the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:278 +msgid "{user} was made editor and promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:286 +msgid "{user} was made editor of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:292 +msgid "{user} was made promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:298 +msgid "{actor} added {user} to the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:309 +msgid "{actor} made you editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:316 +msgid "{actor} made you editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:320 +msgid "{actor} made you promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:324 +msgid "{actor} added you to the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:333 +msgid "You made {user} editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:340 +msgid "You made {user} editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:344 +msgid "You made {user} promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:349 +msgid "You added {user} to the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:358 +msgid "You joined {project} as editor and promoter" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:363 +msgid "You joined {project} as editor" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:367 +msgid "You joined {project} as promoter" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:371 +msgid "You joined the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:385 +msgid "{user} changed their role to editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:394 +msgid "{user} changed their role to editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:401 +msgid "{user} changed their role to promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:408 +msgid "{user} changed their role to crew member of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:414 +msgid "{user}’s role was changed to editor and promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:422 +msgid "{user}’s role was changed to editor of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:429 +msgid "{user}’s role was changed to promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:436 +msgid "{user}’s role was changed to crew member of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:448 +msgid "{actor} changed your role to editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:456 +msgid "{actor} changed your role to editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:462 +msgid "{actor} changed your role to promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:468 +msgid "{actor} changed your role to crew member of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:479 +msgid "You changed {user}’s role to editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:487 +msgid "You changed {user}’s role to editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:493 +msgid "You changed {user}’s role to promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:499 +msgid "You changed {user}’s role to crew member of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:510 +msgid "You are now editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:516 +msgid "You changed your role to editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:521 +msgid "You changed your role to promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:528 +msgid "You changed your role to crew member of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:549 +msgid "{user} resigned as editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:555 +msgid "{user} resigned as editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:560 +msgid "{user} resigned as promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:565 +msgid "{user} resigned from the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:569 +msgid "{user} was removed as editor and promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:577 +msgid "{user} was removed as editor of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:581 +msgid "{user} was removed as promoter of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:587 +msgid "{user} was removed as crew of {project} by {actor}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:596 +msgid "{actor} removed you as editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:603 +msgid "{actor} removed you as editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:607 +msgid "{actor} removed you as promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:611 +msgid "{actor} removed you from the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:619 +msgid "You resigned as editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:625 +msgid "You resigned as editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:630 +msgid "You resigned as promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:635 +msgid "You resigned from the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:639 +msgid "You removed {user} as editor and promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:646 +msgid "You removed {user} as editor of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:650 +msgid "You removed {user} as promoter of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:654 +msgid "You removed {user} from the crew of {project}" +msgstr "" + +#: funnel/views/notifications/project_crew_notification.py:664 +msgid "You are receiving this because you are a crew member of this project" msgstr "" #: funnel/views/notifications/project_starting_notification.py:24 -#: funnel/views/notifications/rsvp_notification.py:63 +#: funnel/views/notifications/rsvp_notification.py:65 #: funnel/views/notifications/update_notification.py:22 msgid "You are receiving this because you have registered for this project" msgstr "" @@ -6345,32 +6943,32 @@ msgstr "" msgid "Your submission has been received in {project}:" msgstr "" -#: funnel/views/notifications/rsvp_notification.py:72 +#: funnel/views/notifications/rsvp_notification.py:74 msgid "Registration confirmation for {project}" msgstr "" -#: funnel/views/notifications/rsvp_notification.py:83 -#: funnel/views/notifications/rsvp_notification.py:131 +#: funnel/views/notifications/rsvp_notification.py:85 +#: funnel/views/notifications/rsvp_notification.py:133 msgid "View project" msgstr "" -#: funnel/views/notifications/rsvp_notification.py:91 +#: funnel/views/notifications/rsvp_notification.py:93 msgid "You have registered for {project}. Next session: {datetime}." msgstr "" -#: funnel/views/notifications/rsvp_notification.py:93 +#: funnel/views/notifications/rsvp_notification.py:95 msgid "You have registered for {project}" msgstr "" -#: funnel/views/notifications/rsvp_notification.py:114 +#: funnel/views/notifications/rsvp_notification.py:116 msgid "You are receiving this because you had registered for this project" msgstr "" -#: funnel/views/notifications/rsvp_notification.py:120 +#: funnel/views/notifications/rsvp_notification.py:122 msgid "Registration cancelled for {project}" msgstr "" -#: funnel/views/notifications/rsvp_notification.py:137 +#: funnel/views/notifications/rsvp_notification.py:139 msgid "You have cancelled your registration for {project}" msgstr "" diff --git a/funnel/transports/base.py b/funnel/transports/base.py index 335c271be..22eb85d2a 100644 --- a/funnel/transports/base.py +++ b/funnel/transports/base.py @@ -2,15 +2,13 @@ from __future__ import annotations -from typing import Dict - from .. import app from .sms import init as sms_init #: List of available transports as platform capabilities. Each is turned on by #: :func:`init` if the necessary functionality and config exist. Views may consult this #: when exposing transport availability to users. -platform_transports: Dict[str, bool] = { +platform_transports: dict[str, bool] = { 'email': False, 'sms': False, 'webpush': False, diff --git a/funnel/transports/email/aws_ses/ses_messages.py b/funnel/transports/email/aws_ses/ses_messages.py index d633587d8..577100c80 100644 --- a/funnel/transports/email/aws_ses/ses_messages.py +++ b/funnel/transports/email/aws_ses/ses_messages.py @@ -5,7 +5,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from enum import Enum -from typing import Dict, List, Optional from dataclasses_json import DataClassJsonMixin, config @@ -31,8 +30,8 @@ class SesMailHeaders(DataClassJsonMixin): class SesCommonMailHeaders(DataClassJsonMixin): """Json object for common mail headers.""" - from_address: List[str] = field(metadata=config(field_name='from')) - to_address: List[str] = field(metadata=config(field_name='to')) + from_address: list[str] = field(metadata=config(field_name='from')) + to_address: list[str] = field(metadata=config(field_name='to')) messageid: str = field(metadata=config(field_name='messageId')) subject: str = '' # Subject may be missing @@ -59,13 +58,13 @@ class SesMail(DataClassJsonMixin): source: str source_arn: str = field(metadata=config(field_name='sourceArn')) sending_accountid: str = field(metadata=config(field_name='sendingAccountId')) - destination: List[str] + destination: list[str] headers_truncated: bool = field(metadata=config(field_name='headersTruncated')) - headers: List[SesMailHeaders] + headers: list[SesMailHeaders] common_headers: SesCommonMailHeaders = field( metadata=config(field_name='commonHeaders') ) - tags: Dict[str, List[str]] + tags: dict[str, list[str]] @dataclass @@ -80,9 +79,9 @@ class SesIndividualRecipient(DataClassJsonMixin): """ email: str = field(metadata=config(field_name='emailAddress')) - action: Optional[str] = None - status: Optional[str] = None - diagnostic_code: Optional[str] = field( + action: str | None = None + status: str | None = None + diagnostic_code: str | None = field( metadata=config(field_name='diagnosticCode'), default=None ) @@ -104,12 +103,12 @@ class SesBounce(DataClassJsonMixin): bounce_type: str = field(metadata=config(field_name='bounceType')) bounce_sub_type: str = field(metadata=config(field_name='bounceSubType')) - bounced_recipients: List[SesIndividualRecipient] = field( + bounced_recipients: list[SesIndividualRecipient] = field( metadata=config(field_name='bouncedRecipients') ) timestamp: str feedbackid: str = field(metadata=config(field_name='feedbackId')) - reporting_mta: Optional[str] = field( + reporting_mta: str | None = field( metadata=config(field_name='reportingMTA'), default=None ) @@ -149,21 +148,21 @@ class SesComplaint(DataClassJsonMixin): * 'virus': A virus is found in the originating message """ - complained_recipients: List[SesIndividualRecipient] = field( + complained_recipients: list[SesIndividualRecipient] = field( metadata=config(field_name='complainedRecipients') ) timestamp: str feedbackid: str = field(metadata=config(field_name='feedbackId')) - complaint_sub_type: Optional[str] = field( + complaint_sub_type: str | None = field( metadata=config(field_name='complaintSubType'), default=None ) - user_agent: Optional[str] = field( + user_agent: str | None = field( metadata=config(field_name='userAgent'), default=None ) - complaint_feedback_type: Optional[str] = field( + complaint_feedback_type: str | None = field( metadata=config(field_name='complaintFeedbackType'), default=None ) - arrival_date: Optional[str] = field( + arrival_date: str | None = field( metadata=config(field_name='arrivalDate'), default=None ) @@ -182,7 +181,7 @@ class SesDelivery(DataClassJsonMixin): timestamp: str processing_time: int = field(metadata=config(field_name='processingTimeMillis')) - recipients: List[str] + recipients: list[str] smtp_response: str = field(metadata=config(field_name='smtpResponse')) reporting_mta: str = field(metadata=config(field_name='reportingMTA')) @@ -235,7 +234,7 @@ class SesClick(DataClassJsonMixin): timestamp: str user_agent: str = field(metadata=config(field_name='userAgent')) link: str - link_tags: Optional[Dict[str, List[str]]] = field( + link_tags: dict[str, list[str]] | None = field( metadata=config(field_name='linkTags'), default=None ) @@ -276,7 +275,7 @@ class SesDeliveryDelay(DataClassJsonMixin): * 'Undetermined': Amazon SES wasn't able to determine the reason """ - delayed_recipients: List[SesIndividualRecipient] = field( + delayed_recipients: list[SesIndividualRecipient] = field( metadata=config(field_name='delayedRecipients') ) expiration_time: str = field(metadata=config(field_name='expirationTime')) @@ -328,15 +327,15 @@ class SesEvent(DataClassJsonMixin): event_type: str = field(metadata=config(field_name='eventType')) mail: SesMail - bounce: Optional[SesBounce] = None - complaint: Optional[SesComplaint] = None - delivery: Optional[SesDelivery] = None - send: Optional[SesSend] = None - reject: Optional[SesReject] = None - opened: Optional[SesOpen] = field(metadata=config(field_name='open'), default=None) - click: Optional[SesClick] = None - failure: Optional[SesRenderFailure] = None - delivery_delay: Optional[SesDeliveryDelay] = field( + bounce: SesBounce | None = None + complaint: SesComplaint | None = None + delivery: SesDelivery | None = None + send: SesSend | None = None + reject: SesReject | None = None + opened: SesOpen | None = field(metadata=config(field_name='open'), default=None) + click: SesClick | None = None + failure: SesRenderFailure | None = None + delivery_delay: SesDeliveryDelay | None = field( metadata=config(field_name='deliveryDelay'), default=None ) diff --git a/funnel/transports/email/aws_ses/sns_notifications.py b/funnel/transports/email/aws_ses/sns_notifications.py index 2e9f2abfe..365354baa 100644 --- a/funnel/transports/email/aws_ses/sns_notifications.py +++ b/funnel/transports/email/aws_ses/sns_notifications.py @@ -2,18 +2,20 @@ from __future__ import annotations -from enum import Enum, IntFlag -from typing import Dict, Pattern, Sequence, cast import base64 import re +from collections.abc import Sequence +from enum import Enum, IntFlag +from re import Pattern +from typing import cast +import requests from cryptography import x509 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from cryptography.hazmat.primitives.hashes import SHA1 -import requests __all__ = [ 'SnsNotificationType', @@ -63,7 +65,7 @@ class SnsValidatorChecks(IntFlag): SIGNATURE_VERSION = 2 CERTIFICATE_URL = 4 SIGNATURE = 8 - ALL = 15 + ALL = 15 # pylint: disable=implicit-flag-alias class SnsValidator: @@ -91,20 +93,20 @@ def __init__( self.cert_regex = cert_regex self.sig_version = sig_version #: Cache of public keys (per Python process) - self.public_keys: Dict[str, RSAPublicKey] = {} + self.public_keys: dict[str, RSAPublicKey] = {} - def _check_topics(self, message: Dict[str, str]) -> None: + def _check_topics(self, message: dict[str, str]) -> None: topic = message.get('TopicArn') if not topic: raise SnsTopicError("No Topic") if topic not in self.topics: raise SnsTopicError("Received topic is not in the list of interest") - def _check_signature_version(self, message: Dict[str, str]) -> None: + def _check_signature_version(self, message: dict[str, str]) -> None: if message.get('SignatureVersion') != self.sig_version: raise SnsSignatureVersionError("Signature version is invalid") - def _check_cert_url(self, message: Dict[str, str]) -> None: + def _check_cert_url(self, message: dict[str, str]) -> None: cert_url = message.get('SigningCertURL') if not cert_url: raise SnsCertURLError("Missing SigningCertURL field in message") @@ -112,7 +114,7 @@ def _check_cert_url(self, message: Dict[str, str]) -> None: raise SnsCertURLError("Invalid certificate URL") @staticmethod - def _get_text_to_sign(message: Dict[str, str]) -> str: + def _get_text_to_sign(message: dict[str, str]) -> str: """ Extract the plain text that was used for signing to compare signatures. @@ -158,7 +160,7 @@ def _get_text_to_sign(message: Dict[str, str]) -> str: pairs = [f'{key}\n{message.get(key)}' for key in keys] return '\n'.join(pairs) + '\n' - def _get_public_key(self, message: Dict[str, str]) -> RSAPublicKey: + def _get_public_key(self, message: dict[str, str]) -> RSAPublicKey: """ Get the public key using an internal per-process cache. @@ -181,7 +183,7 @@ def _get_public_key(self, message: Dict[str, str]) -> RSAPublicKey: raise SnsSignatureFailureError(exc) from exc return public_key - def _check_signature(self, message: Dict[str, str]) -> None: + def _check_signature(self, message: dict[str, str]) -> None: """ Check Signature by comparing the message with the Signature. @@ -196,14 +198,14 @@ def _check_signature(self, message: Dict[str, str]) -> None: signature, plaintext, PKCS1v15(), - SHA1(), # skipcq: PTC-W1003 + SHA1(), # nosec # skipcq: PTC-W1003 ) except InvalidSignature as exc: raise SnsSignatureFailureError("Signature mismatch") from exc def check( self, - message: Dict[str, str], + message: dict[str, str], checks: SnsValidatorChecks = SnsValidatorChecks.ALL, ) -> None: """ diff --git a/funnel/transports/email/send.py b/funnel/transports/email/send.py index 8be57bf26..f31343c0c 100644 --- a/funnel/transports/email/send.py +++ b/funnel/transports/email/send.py @@ -2,33 +2,35 @@ from __future__ import annotations +import smtplib from dataclasses import dataclass -from email.utils import formataddr, getaddresses, parseaddr -from typing import Dict, List, Optional, Tuple, Union +from email.utils import formataddr, getaddresses, make_msgid, parseaddr +from typing import Optional, Union from flask import current_app from flask_mailman import EmailMultiAlternatives from flask_mailman.message import sanitize_address - from html2text import html2text from premailer import transform +from werkzeug.datastructures import Headers -from baseframe import statsd +from baseframe import _, statsd from ... import app -from ...models import EmailAddress, EmailAddressBlockedError, User +from ...models import Account, EmailAddress, EmailAddressBlockedError, Rsvp from ..exc import TransportRecipientError __all__ = [ 'EmailAttachment', 'jsonld_confirm_action', + 'jsonld_event_reservation', 'jsonld_view_action', 'process_recipient', 'send_email', ] # Email recipient type -EmailRecipient = Union[User, Tuple[Optional[str], str], str] +EmailRecipient = Union[Account, tuple[Optional[str], str], str] @dataclass @@ -40,30 +42,83 @@ class EmailAttachment: mimetype: str -def jsonld_view_action(description: str, url: str, title: str) -> Dict[str, object]: +def jsonld_view_action(description: str, url: str, title: str) -> dict[str, object]: + """Schema.org JSON-LD markup for an email view action.""" + return { + '@context': 'https://schema.org', + '@type': 'EmailMessage', + 'description': description, + 'potentialAction': {'@type': 'ViewAction', 'name': title, 'url': url}, + 'publisher': { + '@type': 'Organization', + 'name': current_app.config['SITE_TITLE'], + 'url': 'https://' + current_app.config['DEFAULT_DOMAIN'] + '/', + }, + } + + +def jsonld_confirm_action(description: str, url: str, title: str) -> dict[str, object]: + """Schema.org JSON-LD markup for an email confirmation action.""" return { - "@context": "http://schema.org", - "@type": "EmailMessage", - "description": description, - "potentialAction": {"@type": "ViewAction", "name": title, "url": url}, - "publisher": { - "@type": "Organization", - "name": current_app.config['SITE_TITLE'], - "url": 'https://' + current_app.config['DEFAULT_DOMAIN'] + '/', + '@context': 'https://schema.org', + '@type': 'EmailMessage', + 'description': description, + 'potentialAction': { + '@type': 'ConfirmAction', + 'name': title, + 'handler': {'@type': 'HttpActionHandler', 'url': url}, }, } -def jsonld_confirm_action(description: str, url: str, title: str) -> Dict[str, object]: +def jsonld_event_reservation(rsvp: Rsvp) -> dict[str, object]: + """Schema.org JSON-LD markup for an event reservation.""" + location: str | dict[str, object] + venue = rsvp.project.primary_venue + if venue is not None: + location = { + '@type': 'Place', + 'name': venue.title, + } + if venue.address1: + postal_address = { + '@type': 'PostalAddress', + 'streetAddress': venue.address1, + 'addressLocality': venue.city, + 'addressRegion': venue.state, + 'postalCode': venue.postcode, + 'addressCountry': venue.country, + } + location['address'] = postal_address + else: + location = rsvp.project.location return { - "@context": "http://schema.org", - "@type": "EmailMessage", - "description": description, - "potentialAction": { - "@type": "ConfirmAction", - "name": title, - "handler": {"@type": "HttpActionHandler", "url": url}, + '@context': 'https://schema.org', + '@type': 'EventReservation', + 'reservationNumber': rsvp.uuid_b58, + 'reservationStatus': ( + 'https://schema.org/ReservationConfirmed' + if rsvp.state.YES + else 'https://schema.org/ReservationCancelled' + if rsvp.state.NO + else 'https://schema.org/ReservationPending' + ), + 'underName': { + '@type': 'Person', + 'name': rsvp.participant.fullname, + }, + 'reservationFor': { + '@type': 'Event', + 'name': rsvp.project.joined_title, + 'url': rsvp.project.absolute_url, + 'startDate': rsvp.project.start_at, + 'location': location, + 'performer': { + '@type': 'Organization', + 'name': rsvp.project.account.title, + }, }, + 'numSeats': '1', } @@ -80,15 +135,15 @@ def process_recipient(recipient: EmailRecipient) -> str: :param recipient: Recipient of an email :returns: RFC 2822 formatted string email address """ - if isinstance(recipient, User): - formatted = formataddr((recipient.fullname, str(recipient.email))) + if isinstance(recipient, Account): + formatted = formataddr((recipient.title, str(recipient.email))) elif isinstance(recipient, tuple): formatted = formataddr(recipient) elif isinstance(recipient, str): formatted = recipient else: raise ValueError( - "Not a valid email format. Provide either a User object, or a tuple of" + "Not a valid email format. Provide either an Account object, or a tuple of" " (realname, email), or a preformatted string with Name " ) @@ -110,37 +165,48 @@ def process_recipient(recipient: EmailRecipient) -> str: return formataddr((realname, emailaddr)) -def send_email( # pylint: disable=too-many-arguments +def send_email( subject: str, - to: List[EmailRecipient], + to: list[EmailRecipient], content: str, - attachments: Optional[List[EmailAttachment]] = None, - from_email: Optional[EmailRecipient] = None, - headers: Optional[dict] = None, + attachments: list[EmailAttachment] | None = None, + from_email: EmailRecipient | None = None, + headers: dict | Headers | None = None, + base_url: str | None = None, ) -> str: """ Send an email. - :param str subject: Subject line of email message - :param list to: List of recipients. May contain (a) User objects, (b) tuple of + :param subject: Subject line of email message + :param to: List of recipients. May contain (a) Account objects, (b) tuple of (name, email_address), or (c) a pre-formatted email address - :param str content: HTML content of the message (plain text is auto-generated) - :param list attachments: List of :class:`EmailAttachment` attachments + :param content: HTML content of the message (plain text is auto-generated) + :param attachments: List of :class:`EmailAttachment` attachments :param from_email: Email sender, same format as email recipient - :param dict headers: Optional extra email headers (for List-Unsubscribe, etc) + :param headers: Optional extra email headers (for List-Unsubscribe, etc) + :param base_url: Optional base URL for all relative links in the email """ # Parse recipients and convert as needed to = [process_recipient(recipient) for recipient in to] if from_email: from_email = process_recipient(from_email) body = html2text(content) - html = transform(content, base_url=f'https://{app.config["DEFAULT_DOMAIN"]}/') + html = transform( + content, base_url=base_url or f'https://{app.config["DEFAULT_DOMAIN"]}/' + ) + headers = Headers() if headers is None else Headers(headers) + + # Amazon SES will replace Message-ID, so we keep our original in an X- header + headers['Message-ID'] = headers['X-Original-Message-ID'] = make_msgid( + domain=current_app.config['DEFAULT_DOMAIN'] + ) + msg = EmailMultiAlternatives( subject=subject, to=to, body=body, from_email=from_email, - headers=headers, + headers=dict(headers), # Flask-Mailman<=0.3.0 will trip on a Headers object alternatives=[(html, 'text/html')], ) if attachments: @@ -150,26 +216,58 @@ def send_email( # pylint: disable=too-many-arguments filename=attachment.filename, mimetype=attachment.mimetype, ) + + email_addresses: list[EmailAddress] = [] + for _name, email in getaddresses(msg.recipients()): + try: + # If an EmailAddress is blocked, this line will throw an exception + ea = EmailAddress.add(email) + # If an email address is hard-bouncing, it cannot be emailed or it'll hurt + # sender reputation. There is no automated way to flag an email address as + # no longer bouncing, so it'll require customer support intervention + if ea.delivery_state.HARD_FAIL: + statsd.incr('email_address.send_hard_fail') + raise TransportRecipientError( + _( + "This email address is bouncing messages: {email}. If you" + " believe this to be incorrect, please contact customer support" + ).format(email=email) + ) + email_addresses.append(ea) + except EmailAddressBlockedError as exc: + statsd.incr('email_address.send_blocked') + raise TransportRecipientError( + _("This email address has been blocked: {email}").format(email=email) + ) from exc + try: - # If an EmailAddress is blocked, this line will throw an exception - emails = [ - EmailAddress.add(email) for name, email in getaddresses(msg.recipients()) - ] - except EmailAddressBlockedError as exc: - raise TransportRecipientError(exc) from exc - # FIXME: This won't raise an exception on delivery_state.HARD_FAIL. We need to do - # catch that, remove the recipient, and notify the user via the upcoming - # notification centre. (Raise a TransportRecipientError) - - result = msg.send() + msg.send() + except smtplib.SMTPRecipientsRefused as exc: + if len(exc.recipients) == 1: + if len(to) == 1: + message = _("This email address is not valid") + else: + message = _("This email address is not valid: {email}").format( + email=list(exc.recipients.keys())[0] + ) + else: + if len(to) == len(exc.recipients): + # We don't know which recipients were rejected, so the error message + # can't identify them + message = _("These email addresses are not valid") + else: + message = _("These email addresses are not valid: {emails}").format( + emails=_(", ").join(exc.recipients.keys()) + ) + statsd.incr('email_address.send_smtp_refused') + raise TransportRecipientError(message) from exc # After sending, mark the address as having received an email and also update the # statistics counters. Note that this will only track emails sent by *this app*. # However SES events will track statistics across all apps and hence the difference # between this counter and SES event counters will be emails sent by other apps. - statsd.incr('email_address.sent', count=len(emails)) - for ea in emails: + statsd.incr('email_address.sent', count=len(email_addresses)) + for ea in email_addresses: ea.mark_sent() - # FIXME: 'result' is a number. Why? We need message-id - return str(result) + return headers['Message-ID'] diff --git a/funnel/transports/sms/send.py b/funnel/transports/sms/send.py index 6daa2513f..d2b8a0d46 100644 --- a/funnel/transports/sms/send.py +++ b/funnel/transports/sms/send.py @@ -2,18 +2,20 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, List, Optional, Tuple, Union, cast +from typing import cast -from flask import url_for import itsdangerous - -from twilio.base.exceptions import TwilioRestException -from twilio.rest import Client import phonenumbers import requests +from flask import url_for +from pytz import timezone +from twilio.base.exceptions import TwilioRestException +from twilio.rest import Client from baseframe import _ +from coaster.utils import utcnow from ... import app from ...models import PhoneNumber, PhoneNumberBlockedError, sa @@ -23,17 +25,19 @@ TransportRecipientError, TransportTransactionError, ) -from .template import SmsTemplate +from .template import SmsPriority, SmsTemplate __all__ = [ 'make_exotel_token', 'validate_exotel_token', 'send_via_exotel', 'send_via_twilio', - 'send', + 'send_sms', 'init', ] +indian_timezone = timezone('Asia/Kolkata') + @dataclass class SmsSender: @@ -42,11 +46,11 @@ class SmsSender: prefix: str requires_config: set func: Callable - init: Optional[Callable] = None + init: Callable | None = None def get_phone_number( - phone: Union[str, phonenumbers.PhoneNumber, PhoneNumber] + phone: str | phonenumbers.PhoneNumber | PhoneNumber, ) -> PhoneNumber: if isinstance(phone, PhoneNumber): if not phone.number: @@ -56,8 +60,10 @@ def get_phone_number( phone_number = PhoneNumber.add(phone) except PhoneNumberBlockedError as exc: raise TransportRecipientError(_("This phone number has been blocked")) from exc - if not phone_number.allow_sms: - raise TransportRecipientError(_("SMS is disabled for this phone number")) + if phone_number.has_sms is False: + raise TransportRecipientError( + _("This phone number cannot receive text messages") + ) if not phone_number.number: # This should never happen as :meth:`PhoneNumber.add` will restore the number raise TransportRecipientError(_("This phone number is not available")) @@ -96,8 +102,16 @@ def validate_exotel_token(token: str, to: str) -> bool: return True +def okay_to_message_in_india_right_now() -> bool: + """Report if it's currently within messaging hours in India (9 AM to 7PM IST).""" + now = utcnow().astimezone(indian_timezone) + if now.hour >= 9 and now.hour < 19: + return True + return False + + def send_via_exotel( - phone: Union[str, phonenumbers.PhoneNumber, PhoneNumber], + phone: str | phonenumbers.PhoneNumber | PhoneNumber, message: SmsTemplate, callback: bool = True, ) -> str: @@ -119,8 +133,19 @@ def send_via_exotel( 'Body': str(message), 'DltEntityId': message.registered_entityid, } - if message.registered_templateid: - payload['DltTemplateId'] = message.registered_templateid + if not message.registered_templateid: + app.logger.warning( + "Dropping SMS message with unknown template id: %s", str(message) + ) + return '' + if ( + message.message_priority in (SmsPriority.OPTIONAL, SmsPriority.NORMAL) + and not okay_to_message_in_india_right_now() + ): + # TODO: Implement deferred sending for `NORMAL` priority + app.logger.warning("Dropping SMS message in DND time: %s", str(message)) + return '' + payload['DltTemplateId'] = message.registered_templateid if callback: payload['StatusCallback'] = url_for( 'process_exotel_event', @@ -154,7 +179,7 @@ def send_via_exotel( def send_via_twilio( - phone: Union[str, phonenumbers.PhoneNumber, PhoneNumber], + phone: str | phonenumbers.PhoneNumber | PhoneNumber, message: SmsTemplate, callback: bool = True, ) -> str: @@ -225,7 +250,7 @@ def send_via_twilio( ) from exc app.logger.error("Unhandled Twilio error %d: %s", exc.code, exc.msg) raise TransportTransactionError( - _("Hasgeek was unable to send a message to this phone number") + _("Hasgeek cannot send an SMS message to this phone number at this time") ) from exc @@ -245,7 +270,14 @@ def send_via_twilio( ] #: Available senders as per config -senders_by_prefix: List[Tuple[str, Callable[[str, SmsTemplate, bool], str]]] = [] +senders_by_prefix: list[ + tuple[ + str, + Callable[ + [str | phonenumbers.PhoneNumber | PhoneNumber, SmsTemplate, bool], str + ], + ] +] = [] def init() -> bool: @@ -258,8 +290,8 @@ def init() -> bool: return bool(senders_by_prefix) -def send( - phone: Union[str, phonenumbers.PhoneNumber, PhoneNumber], +def send_sms( + phone: str | phonenumbers.PhoneNumber | PhoneNumber, message: SmsTemplate, callback: bool = True, ) -> str: diff --git a/funnel/transports/sms/template.py b/funnel/transports/sms/template.py index 011a03cb0..65b0d55bf 100644 --- a/funnel/transports/sms/template.py +++ b/funnel/transports/sms/template.py @@ -2,13 +2,17 @@ from __future__ import annotations -from string import Formatter -from typing import Any, Dict, Optional, Pattern, cast import re +from enum import Enum +from re import Pattern +from string import Formatter +from typing import Any, ClassVar, cast from flask import Flask __all__ = [ + 'DLT_VAR_MAX_LENGTH', + 'SmsPriority', 'SmsTemplate', 'WebOtpTemplate', 'OneLineTemplate', @@ -27,7 +31,14 @@ #: The maximum number of characters that can appear under one {#var#} #: Unclear in documentation: are exempted characters excluded from this length limit? -VAR_MAX_LENGTH = 30 +DLT_VAR_MAX_LENGTH = 30 + + +class SmsPriority(Enum): + URGENT = 1 # For OTPs and time-sensitive messages + IMPORTANT = 2 # For messaging any time of the day, including during DND hours + OPTIONAL = 3 # Okay to drop this message if not sent at a good time + NORMAL = 4 # Everything else, will not be sent during DND hours, TODO requeue class SmsTemplate: @@ -39,8 +50,9 @@ class SmsTemplate: validated to match each other when the class is created:: class MyTemplate(SmsTemplate): - registered_template = "Insert {#var#} here" + registered_template = 'Insert {#var#} here' template = "Insert {var} here" + plaintext_template = "Simplified template also embedding {var}" var: str # Declare variable type like this @@ -107,32 +119,36 @@ def truncate(self): 'You have a message from Rincewind' """ + #: Maximum length for a single variable as per the spec + var_max_length: ClassVar[int] = DLT_VAR_MAX_LENGTH #: Registered entity id - registered_entityid: Optional[str] = None + registered_entityid: ClassVar[str | None] = None #: Registered template id - registered_templateid: Optional[str] = None + registered_templateid: ClassVar[str | None] = None #: Registered template, using `{#var#}` where variables should appear - registered_template: str = "" + registered_template: ClassVar[str] = "" #: Python template, with formatting variables as {var} - template: str = "" + template: ClassVar[str] = "" #: Optional plaintext Python template without validation against registered template - plaintext_template: str = "" + plaintext_template: ClassVar[str] = "" + #: Message delivery priority + message_priority: ClassVar[SmsPriority] = SmsPriority.NORMAL - #: Autogenerated regex version of registered template - registered_template_re: Pattern = re.compile('') # Will be replaced in subclasses + #: Autogenerated regex version of registered template, will be updated in subclasses + registered_template_re: ClassVar[Pattern] = re.compile('') #: Autogenerated count of static characters in registered template - registered_template_static_len: int = 0 # Will be replaced in subclasses + registered_template_static_len: ClassVar[int] = 0 # Will be replaced in subclasses #: Autogenerated count of characters available in variables - registered_template_var_len: int = 0 # Will be replaced in subclasses + registered_template_var_len: ClassVar[int] = 0 # Will be replaced in subclasses # Type hints for mypy. These attributes are set in __init__ - _text: Optional[str] - _plaintext: Optional[str] - _format_kwargs: Dict[str, object] - template_static_len: int + _text: str | None + _plaintext: str | None + _format_kwargs: dict[str, Any] + template_static_len: ClassVar[int] template_var_len: int - def __init__(self, **kwargs) -> None: + def __init__(self, **kwargs: Any) -> None: """Initialize template with variables.""" object.__setattr__(self, '_text', None) object.__setattr__(self, '_plaintext', None) @@ -149,7 +165,7 @@ def __init__(self, **kwargs) -> None: # vformat only needs __getitem__, so ignore mypy's warning about arg type. # The expected type is Mapping[str, Any] len( - Formatter().vformat( # type: ignore[arg-type, call-overload] + Formatter().vformat( # type: ignore[call-overload] self.template, (), self ) ), @@ -166,9 +182,11 @@ def __init__(self, **kwargs) -> None: - self.template_static_len, ) # Next, store real format field values - self._format_kwargs.update(kwargs) + for arg, value in kwargs.items(): + # Use setattr so subclasses can define special behaviour + setattr(self, arg, value) - def available_var_len(self): + def available_var_len(self) -> int: """ Available length for variable characters, to truncate as necessary. @@ -187,7 +205,7 @@ def format(self) -> None: # noqa: A003 '_plaintext', # vformat only needs __getitem__, so ignore mypy's warning about arg type. # The expected type is Mapping[str, Any] - Formatter().vformat( # type: ignore[arg-type, call-overload] + Formatter().vformat( # type: ignore[call-overload] self.plaintext_template, (), self ) if self.plaintext_template @@ -199,9 +217,7 @@ def format(self) -> None: # noqa: A003 '_text', # vformat only needs __getitem__, so ignore mypy's warning about arg type. # The expected type is Mapping[str, Any] - Formatter().vformat( # type: ignore[arg-type, call-overload] - self.template, (), self - ), + Formatter().vformat(self.template, (), self), # type: ignore[call-overload] ) @property @@ -239,14 +255,24 @@ def __getitem__(self, key: str) -> Any: """Get a format variable via dictionary access, defaulting to ''.""" return getattr(self, key, '') - def __setattr__(self, attr: str, value) -> None: + def __setattr__(self, attr: str, value: Any) -> None: """Set a format variable.""" - self._format_kwargs[attr] = value - object.__setattr__(self, '_text', None) - # We do not reset `_plaintext` here as the `plaintext` property checks only - # `_text`. This is because `format()` calls `truncate()`, which may update a - # variable, which will call `__setattr__`. At this point `_plaintext` has - # already been set by `.format()` and should not be reset. + clsattr = getattr(self.__class__, attr, None) + if clsattr is not None: + # If this attr is from the class, handover processing to object + object.__setattr__(self, attr, value) + else: + # If not, assume template variable + self._format_kwargs[attr] = value + object.__setattr__(self, '_text', None) + # We do not reset `_plaintext` here as the `plaintext` property checks only + # `_text`. This is because `format()` calls `truncate()`, which may update a + # variable, which will call `__setattr__`. At this point `_plaintext` has + # already been set by `.format()` and should not be reset. + + def vars(self) -> dict[str, Any]: # noqa: A003 + """Return a dictionary of variables in the template.""" + return dict(self._format_kwargs) @classmethod def validate_registered_template(cls) -> None: @@ -268,7 +294,7 @@ def validate_registered_template(cls) -> None: _var_repeat_re.sub('', cls.registered_template) ) cls.registered_template_var_len = ( - cls.registered_template.count('{#var#}') * VAR_MAX_LENGTH + cls.registered_template.count('{#var#}') * DLT_VAR_MAX_LENGTH ) # 3. Create a compiled regex for the registered template that replaces @@ -319,10 +345,15 @@ def validate_template(cls) -> None: @classmethod def validate_no_entity_template_id(cls) -> None: """Validate that confidential information is not present in the class spec.""" - if cls.registered_entityid is not None or cls.registered_templateid is not None: + if ( + 'registered_entityid' in cls.__dict__ + or 'registered_templateid' in cls.__dict__ + ): raise TypeError( - "Registered entity id and template id are not public information and" - " must be in config. Use init_app to load config" + f"Registered entity id and template id are not public information and" + f" must be in config. Use init_app to load config (class has:" + f" registered_entityid={cls.registered_entityid}," + f" registered_templateid={cls.registered_templateid})" ) def __init_subclass__(cls) -> None: @@ -333,7 +364,7 @@ def __init_subclass__(cls) -> None: cls.validate_template() @classmethod - def init_subclass_config(cls, app: Flask, config: Dict[str, str]) -> None: + def init_subclass_config(cls, app: Flask, config: dict[str, str]) -> None: """Recursive init for setting template ids in subclasses.""" for subcls in cls.__subclasses__(): subcls_config_name = ''.join( @@ -370,30 +401,32 @@ class WebOtpTemplate(SmsTemplate): """Template for Web OTPs.""" registered_template = ( - 'OTP is {#var#} for Hasgeek.\n\nNot you? Block misuse: {#var#}\n\n' - '@{#var#} #{#var#}' + 'OTP is {#var#} for Hasgeek. If you did not request this, report misuse at' + ' https://has.gy/not-my-otp\n\n@hasgeek.com #{#var#}' ) template = ( - 'OTP is {otp} for Hasgeek.\n\nNot you? Block misuse: {helpline_text}\n\n' - '@{domain} #{otp}' + "OTP is {otp} for Hasgeek. If you did not request this, report misuse at" + " https://has.gy/not-my-otp\n\n@hasgeek.com #{otp}" ) plaintext_template = ( - 'OTP is {otp} for Hasgeek.\n\nNot you? Block misuse: {helpline_text}' + "OTP is {otp} for Hasgeek. If you did not request this, report misuse at" + " https://has.gy/not-my-otp\n\n@hasgeek.com #{otp}" ) + message_priority = SmsPriority.URGENT class OneLineTemplate(SmsTemplate): """Template for single line messages.""" registered_template = '{#var#}{#var#}{#var#}{#var#}\n\n{#var#} to stop - Hasgeek' - template = '{text1} {url}\n\n\n{unsubscribe_url} to stop - Hasgeek' - plaintext_template = '{text1} {url}' + template = "{text1} {url}\n\n\n{unsubscribe_url} to stop - Hasgeek" + plaintext_template = "{text1} {url}" text1: str url: str unsubscribe_url: str - def available_var_len(self): + def available_var_len(self) -> int: """Discount the two URLs from available length.""" return self.template_var_len - len(self.url) - len(self.unsubscribe_url) @@ -410,15 +443,15 @@ class TwoLineTemplate(SmsTemplate): registered_template = ( '{#var#}{#var#}\n\n{#var#}{#var#}\n\n{#var#} to stop - Hasgeek' ) - template = '{text1}\n\n{text2} {url}\n\n\n{unsubscribe_url} to stop - Hasgeek' - plaintext_template = '{text1}\n\n{text2} {url}' + template = "{text1}\n\n{text2} {url}\n\n\n{unsubscribe_url} to stop - Hasgeek" + plaintext_template = "{text1}\n\n{text2} {url}" text1: str text2: str url: str unsubscribe_url: str - def available_var_len(self): + def available_var_len(self) -> int: """Discount the two URLs from available length.""" return self.template_var_len - len(self.url) - len(self.unsubscribe_url) @@ -437,13 +470,13 @@ def truncate(self) -> None: class MessageTemplate(OneLineTemplate): """Template for a message without a URL.""" - template = '{message}\n\n\n{unsubscribe_url} to stop - Hasgeek' - plaintext_template = '{message}' + template = "{message}\n\n\n{unsubscribe_url} to stop - Hasgeek" + plaintext_template = "{message}" message: str unsubscribe_url: str - def available_var_len(self): + def available_var_len(self) -> int: """Discount the unsubscribe URL from available length.""" return self.template_var_len - len(self.unsubscribe_url) diff --git a/funnel/typing.py b/funnel/typing.py index ff57dddd9..65302c043 100644 --- a/funnel/typing.py +++ b/funnel/typing.py @@ -2,127 +2,41 @@ from __future__ import annotations -from typing import ( - Any, - Callable, - Dict, - Generic, - List, - Optional, - Set, - Tuple, - TypeVar, - Union, -) -from uuid import UUID +from typing import Optional, TypeAlias, TypeVar, Union +from typing_extensions import ParamSpec +from flask.typing import ResponseReturnValue from werkzeug.wrappers import Response # Base class for Flask Response -from typing_extensions import ParamSpec, Protocol - -from coaster.sqlalchemy import Query +from coaster.views import ReturnRenderWith __all__ = [ 'T', + 'T_co', 'P', - 'ModelType', - 'UuidModelType', - 'Mapped', 'OptionalMigratedTables', 'ReturnRenderWith', 'ReturnResponse', 'ReturnView', - 'WrappedFunc', - 'ReturnDecorator', 'ResponseType', + 'ResponseReturnValue', ] #: Type used to indicate type continuity within a block of code T = TypeVar('T') +T_co = TypeVar('T_co', covariant=True) #: Type used to indicate parameter continuity within a block of code P = ParamSpec('P') -try: - from sqlalchemy.orm import Mapped # type: ignore[attr-defined] -except ImportError: - # sqlalchemy-stubs (by Dropbox) doesn't define Mapped - # sqlalchemy2-stubs (by SQLAlchemy) does. Redefine if not known here: - from sqlalchemy.orm.attributes import QueryableAttribute - - class Mapped(QueryableAttribute, Generic[T]): # type: ignore[no-redef] - """Replacement for sqlalchemy's Mapped type, for when not using the plugin.""" - - def __get__(self, instance, owner): - raise NotImplementedError() - - def __set__(self, instance, value): - raise NotImplementedError() - - def __delete__(self, instance): - raise NotImplementedError() - - -class ModelType(Protocol): - """Protocol class for models.""" - - __tablename__: str - query: Query - - -class UuidModelType(ModelType): - """Protocol class for models with UUID column.""" - - uuid: Mapped[UUID] - - -#: Flask response headers can be a dict or list of key-value pairs -ResponseHeaders = Union[Dict[str, str], List[Tuple[str, str]]] - -#: Flask views accept a response status code that is either an int or a string -ResponseStatusCode = Union[int, str] - -#: Flask views can return a Response, a string or a JSON dictionary -ResponseTypes = Union[ - str, # A string (typically `render_template`) - Response, # Fully formed response object - Dict[str, Any], # JSON response -] - #: Return type for Flask views (formats accepted by :func:`~flask.make_response`) -ReturnView = Union[ - ResponseTypes, # Only a response - Tuple[ResponseTypes, ResponseStatusCode], # Response + status code - Tuple[ResponseTypes, ResponseHeaders], # Response + headers - Tuple[ - ResponseTypes, ResponseStatusCode, ResponseHeaders - ], # Response + status code + headers -] - -#: Type used for functions and methods wrapped in a decorator -WrappedFunc = TypeVar('WrappedFunc', bound=Callable) -#: Return type for decorator factories -ReturnDecorator = Callable[[WrappedFunc], WrappedFunc] +ReturnView: TypeAlias = ResponseReturnValue #: Return type of the `migrate_user` and `migrate_profile` methods -OptionalMigratedTables = Optional[Union[List[str], Tuple[str], Set[str]]] - -#: JSON and Jinja2 compatible dict type. Cannot be a strict definition because a JSON -#: structure can have a nested dict with the same rules, requiring recursion. Mypy does -#: not support recursive types: https://github.com/python/mypy/issues/731. Both JSON -#: and Jinja2 templates require the dictionary key to be a string. -RenderWithDict = Dict[str, object] - -#: Return type for @render_with decorated views, a subset of Flask view return types -ReturnRenderWith = Union[ - RenderWithDict, # A dict of template variables - Tuple[RenderWithDict, int], # Dict + HTTP status code - Tuple[RenderWithDict, int, Dict[str, str]], # Dict + status code + HTTP headers - Response, # Fully formed Response object -] +OptionalMigratedTables: TypeAlias = Optional[Union[list[str], tuple[str], set[str]]] #: Return type for Response objects -ReturnResponse = Response +ReturnResponse: TypeAlias = Response #: Response typevar ResponseType = TypeVar('ResponseType', bound=Response) diff --git a/funnel/utils/markdown/__init__.py b/funnel/utils/markdown/__init__.py index 65c6cac0f..d7a1fbcca 100644 --- a/funnel/utils/markdown/__init__.py +++ b/funnel/utils/markdown/__init__.py @@ -2,3 +2,4 @@ # flake8: noqa from .base import * +from .escape import * diff --git a/funnel/utils/markdown/base.py b/funnel/utils/markdown/base.py index e075ea025..7b933eea7 100644 --- a/funnel/utils/markdown/base.py +++ b/funnel/utils/markdown/base.py @@ -1,167 +1,101 @@ """Markdown parser and config profiles.""" -# pylint: disable=too-many-arguments from __future__ import annotations +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass -from typing import ( - Any, - Callable, - ClassVar, - Dict, - Iterable, - Optional, - Set, - Union, - overload, -) -import re +from typing import Any, ClassVar, Literal, overload +from typing_extensions import Self from markdown_it import MarkdownIt from markupsafe import Markup -from mdit_py_plugins import anchors, deflist, footnote, tasklists -from typing_extensions import Literal +from mdit_py_plugins import anchors, container, deflist, footnote, tasklists from coaster.utils import make_name from coaster.utils.text import normalize_spaces_multiline from .mdit_plugins import ( # toc_plugin, + abbr_plugin, block_code_extend_plugin, del_plugin, embeds_plugin, footnote_extend_plugin, + heading_anchors_fix_plugin, ins_plugin, mark_plugin, multimd_table_plugin, sub_plugin, sup_plugin, ) +from .tabs import render_tab -__all__ = ['MarkdownPlugin', 'MarkdownConfig', 'MarkdownString', 'markdown_escape'] - -# --- Markdown escaper and string ------------------------------------------------------ - -#: Based on the ASCII punctuation list in the CommonMark spec at -#: https://spec.commonmark.org/0.30/#backslash-escapes -markdown_escape_re = re.compile(r"""([\[\\\]{|}\(\)`~!@#$%^&*=+;:'"<>/,.?_-])""") - - -def markdown_escape(text: str) -> MarkdownString: - """ - Escape all Markdown formatting characters and strip whitespace at ends. - - As per the CommonMark spec, all ASCII punctuation can be escaped with a backslash - and compliant parsers will then render the punctuation mark as a literal character. - However, escaping any other character will cause the backslash to be rendered. This - escaper therefore targets only ASCII punctuation characters listed in the spec. - - Edge whitespace is significant in Markdown and must be stripped when escaping: - - * Four spaces at the start will initiate a code block - * Two spaces at the end will cause a line-break in non-GFM Markdown - - Replacing these spaces with   is not suitable because non-breaking spaces - affect HTML rendering, specifically the CSS ``white-space: normal`` sequence - collapsing behaviour. - - :returns: Escaped text as an instance of :class:`MarkdownString`, to avoid - double-escaping - """ - if hasattr(text, '__markdown__'): - return MarkdownString(text.__markdown__()) - return MarkdownString(markdown_escape_re.sub(r'\\\1', text).strip()) - - -class MarkdownString(str): - """Markdown string, implements a __markdown__ method.""" - - __slots__ = () - - def __new__( - cls, base: Any = '', encoding: Optional[str] = None, errors: str = 'strict' - ) -> MarkdownString: - if hasattr(base, '__markdown__'): - base = base.__markdown__() - - if encoding is None: - return super().__new__(cls, base) - - return super().__new__(cls, base, encoding, errors) - - def __markdown__(self) -> MarkdownString: - """Return a markdown source string.""" - return self - - @classmethod - def escape(cls, text: str) -> MarkdownString: - """Escape a string.""" - rv = markdown_escape(text) - - if rv.__class__ is not cls: - return cls(rv) - - return rv - - # TODO: Implement other methods supported by markupsafe +__all__ = [ + 'MarkdownPlugin', + 'MarkdownConfig', + 'markdown_basic', + 'markdown_document', + 'markdown_mailer', + 'markdown_inline', +] # --- Markdown dataclasses ------------------------------------------------------------- -OptionStrings = Literal['html', 'breaks', 'linkify', 'typographer'] - @dataclass class MarkdownPlugin: """Markdown plugin registry with configuration.""" - #: Registry of named sub-classes - registry: ClassVar[Dict[str, MarkdownConfig]] = {} + #: Registry of instances + registry: ClassVar[dict[str, MarkdownPlugin]] = {} - #: Optional name for this config, for adding to the registry - name: str + #: Optional name for this config + name: str | None func: Callable - config: Optional[Dict[str, Any]] = None + config: dict[str, Any] | None = None - def __post_init__(self): - # If this plugin+configuration has a name, add it to the registry - if self.name is not None: - if self.name in self.registry: - raise NameError(f"Plugin {self.name} has already been registered") - self.registry[self.name] = self + @classmethod + def register(cls, name: str, *args, **kwargs) -> Self: + """Create a new instance and add it to the registry.""" + if name in cls.registry: + raise NameError(f"MarkdownPlugin {name} has already been registered") + obj = cls(name, *args, **kwargs) + cls.registry[name] = obj + return obj @dataclass class MarkdownConfig: """Markdown processor with custom configuration, with a registry.""" - #: Registry of named sub-classes - registry: ClassVar[Dict[str, MarkdownConfig]] = {} + #: Registry of named instances + registry: ClassVar[dict[str, MarkdownConfig]] = {} #: Optional name for this config, for adding to the registry - name: Optional[str] = None + name: str | None = None #: Markdown-it preset configuration preset: Literal[ 'default', 'zero', 'commonmark', 'js-default', 'gfm-like' ] = 'commonmark' #: Updated options against the preset - options_update: Optional[Dict[OptionStrings, bool]] = None + options_update: Mapping | None = None #: Allow only inline rules (skips all block rules)? inline: bool = False #: Use these plugins - plugins: Iterable[Union[str, MarkdownPlugin]] = () + plugins: Iterable[str | MarkdownPlugin] = () #: Enable these rules (provided by plugins) - enable_rules: Optional[Set[str]] = None + enable_rules: set[str] | None = None #: Disable these rules - disable_rules: Optional[Set[str]] = None + disable_rules: set[str] | None = None #: If linkify is enabled, apply to fuzzy links too? linkify_fuzzy_link: bool = False #: If linkify is enabled, make email links too? linkify_fuzzy_email: bool = False - def __post_init__(self): + def __post_init__(self) -> None: try: self.plugins = [ MarkdownPlugin.registry[plugin] if isinstance(plugin, str) else plugin @@ -170,11 +104,14 @@ def __post_init__(self): except KeyError as exc: raise TypeError(f"Unknown Markdown plugin {exc.args[0]}") from None - # If this plugin+configuration has a name, add it to the registry - if self.name is not None: - if self.name in self.registry: - raise NameError(f"Config {self.name} has already been registered") - self.registry[self.name] = self + @classmethod + def register(cls, name: str, *args, **kwargs) -> Self: + """Create a new instance and add it to the registry.""" + if name in cls.registry: + raise NameError(f"MarkdownConfig {name} has already been registered") + obj = cls(name, *args, **kwargs) + cls.registry[name] = obj + return obj @overload def render(self, text: None) -> None: @@ -184,13 +121,16 @@ def render(self, text: None) -> None: def render(self, text: str) -> Markup: ... - def render(self, text: Optional[str]) -> Optional[Markup]: + def render(self, text: str | None) -> Markup | None: """Parse and render Markdown using markdown-it-py with the selected config.""" if text is None: return None - # Replace invisible characters with spaces - text = normalize_spaces_multiline(text) + # Recast MarkdownString as a plain string and normalize all space chars + text = normalize_spaces_multiline(str(text)) + # XXX: this also replaces a tab with a single space. This will be a problem if + # the tab char has semantic meaning, such as in an embedded code block for a + # tab-sensitive syntax like a Makefile md = MarkdownIt(self.preset, self.options_update or {}) @@ -217,9 +157,11 @@ def render(self, text: Optional[str]) -> Optional[Markup]: # --- Markdown plugins ----------------------------------------------------------------- -MarkdownPlugin('deflists', deflist.deflist_plugin) -MarkdownPlugin('footnote', footnote.footnote_plugin) -MarkdownPlugin( + +MarkdownPlugin.register('abbr', abbr_plugin) +MarkdownPlugin.register('deflists', deflist.deflist_plugin) +MarkdownPlugin.register('footnote', footnote.footnote_plugin) +MarkdownPlugin.register( 'heading_anchors', anchors.anchors_plugin, { @@ -228,35 +170,50 @@ def render(self, text: Optional[str]) -> Optional[Markup]: 'slug_func': lambda x: 'h:' + make_name(x), 'permalink': True, 'permalinkSymbol': '#', + 'permalinkSpace': False, }, ) -MarkdownPlugin( +# The heading_anchors_fix plugin modifies the token stream output of heading_anchors +# plugin to make the heading a permalink instead of a separate permalink. It eliminates +# the extra character and strips any links inside the heading that may have been +# introduced by the author. +MarkdownPlugin.register('heading_anchors_fix', heading_anchors_fix_plugin) + +MarkdownPlugin.register( 'tasklists', tasklists.tasklists_plugin, {'enabled': True, 'label': True, 'label_after': False}, ) -MarkdownPlugin('ins', ins_plugin) -MarkdownPlugin('del', del_plugin) -MarkdownPlugin('sub', sub_plugin) -MarkdownPlugin('sup', sup_plugin) -MarkdownPlugin('mark', mark_plugin) -MarkdownPlugin('markmap', embeds_plugin, {'name': 'markmap'}) -MarkdownPlugin('vega-lite', embeds_plugin, {'name': 'vega-lite'}) -MarkdownPlugin('mermaid', embeds_plugin, {'name': 'mermaid'}) -MarkdownPlugin('block_code_ext', block_code_extend_plugin) -MarkdownPlugin('footnote_ext', footnote_extend_plugin) -MarkdownPlugin('multimd_table', multimd_table_plugin, {'multiline': True}) -# MarkdownPlugin('toc', toc_plugin) +MarkdownPlugin.register('ins', ins_plugin) +MarkdownPlugin.register('del', del_plugin) +MarkdownPlugin.register('sub', sub_plugin) +MarkdownPlugin.register('sup', sup_plugin) +MarkdownPlugin.register('mark', mark_plugin) + +MarkdownPlugin.register( + 'tab_container', + container.container_plugin, + {'name': 'tab', 'marker': ':', 'render': render_tab}, +) +MarkdownPlugin.register('markmap', embeds_plugin, {'name': 'markmap'}) +MarkdownPlugin.register('vega_lite', embeds_plugin, {'name': 'vega-lite'}) +MarkdownPlugin.register('mermaid', embeds_plugin, {'name': 'mermaid'}) +MarkdownPlugin.register('block_code_ext', block_code_extend_plugin) +MarkdownPlugin.register('footnote_ext', footnote_extend_plugin) +MarkdownPlugin.register('multimd_table', multimd_table_plugin, {'multiline': True}) +# The TOC plugin isn't yet working +# MarkdownPlugin.register('toc', toc_plugin) + # --- Markdown configurations ---------------------------------------------------------- -MarkdownConfig( +markdown_basic = MarkdownConfig.register( name='basic', options_update={'html': False, 'breaks': True}, plugins=['block_code_ext'], ) -MarkdownConfig( +markdown_document = MarkdownConfig.register( name='document', preset='gfm-like', options_update={ @@ -266,12 +223,15 @@ def render(self, text: Optional[str]) -> Optional[Markup]: 'breaks': True, }, plugins=[ + 'tab_container', + 'abbr', 'block_code_ext', 'deflists', 'multimd_table', 'footnote', 'footnote_ext', # Must be after 'footnote' to take effect 'heading_anchors', + 'heading_anchors_fix', # Must be after 'heading_anchors' to take effect 'tasklists', 'ins', 'del', @@ -279,13 +239,27 @@ def render(self, text: Optional[str]) -> Optional[Markup]: 'sup', 'mark', 'markmap', - 'vega-lite', + 'vega_lite', 'mermaid', # 'toc', ], enable_rules={'smartquotes'}, ) +markdown_mailer = MarkdownConfig.register( + name='mailer', + preset='gfm-like', + options_update={ + 'html': True, + 'linkify': True, + 'typographer': True, + 'breaks': True, + }, + plugins=markdown_document.plugins, + enable_rules={'smartquotes'}, + linkify_fuzzy_email=True, +) + #: This profile is meant for inline fields (like Title) and allows for only inline #: visual markup: emphasis, code, ins/underline, del/strikethrough, superscripts, #: subscripts and smart quotes. It does not allow hyperlinks, images or HTML tags. @@ -294,7 +268,7 @@ def render(self, text: Optional[str]) -> Optional[Markup]: #: Unicode characters for bold/italic/sub/sup, but found this unsuitable as these #: character ranges are not comprehensive. Instead, plaintext use will include the #: Markdown formatting characters as-is. -MarkdownConfig( +markdown_inline = MarkdownConfig.register( name='inline', preset='zero', options_update={'html': False, 'breaks': False, 'typographer': True}, diff --git a/funnel/utils/markdown/escape.py b/funnel/utils/markdown/escape.py new file mode 100644 index 000000000..a83a68f84 --- /dev/null +++ b/funnel/utils/markdown/escape.py @@ -0,0 +1,299 @@ +"""Markdown escaper.""" + +from __future__ import annotations + +import re +import string +from collections.abc import Callable, Iterable, Mapping +from functools import wraps +from typing import Any, Concatenate, SupportsIndex, TypeVar +from typing_extensions import ParamSpec, Protocol, Self + +__all__ = ['HasMarkdown', 'MarkdownString', 'markdown_escape'] + + +_P = ParamSpec('_P') +_ListOrDict = TypeVar('_ListOrDict', list, dict) + + +class HasMarkdown(Protocol): + """Protocol for a class implementing :meth:`__markdown__`.""" + + def __markdown__(self) -> str: + """Return a Markdown string.""" + + +#: Based on the ASCII punctuation list in the CommonMark spec at +#: https://spec.commonmark.org/0.30/#backslash-escapes +markdown_escape_re = re.compile(r"""([\[\\\]{|}\(\)`~!@#$%^&*=+;:'"<>/,.?_-])""") +#: Unescape regex has a `\` prefix and the same characters +markdown_unescape_re = re.compile(r"""\\([\[\\\]{|}\(\)`~!@#$%^&*=+;:'"<>/,.?_-])""") + + +class _MarkdownEscapeFormatter(string.Formatter): + """Support class for :meth:`MarkdownString.format`.""" + + __slots__ = ('escape',) + + def __init__(self, escape: Callable[[Any], MarkdownString]) -> None: + self.escape = escape + super().__init__() + + def format_field(self, value: Any, format_spec: str) -> str: + if hasattr(value, '__markdown_format__'): + rv = value.__markdown_format__(format_spec) + elif hasattr(value, '__markdown__'): + if format_spec: + raise ValueError( + f"Format specifier {format_spec} given, but {type(value)} does not" + " define __markdown_format__. A class that defines __markdown__" + " must define __markdown_format__ to work with format specifiers." + ) + rv = value.__markdown__() + else: + # We need to make sure the format spec is str here as + # otherwise the wrong callback methods are invoked. + rv = string.Formatter.format_field(self, value, str(format_spec)) + return str(self.escape(rv)) + + +class _MarkdownEscapeHelper: + """Helper for :meth:`MarkdownString.__mod__`.""" + + __slots__ = ('obj', 'escape') + + def __init__(self, obj: Any, escape: Callable[[Any], MarkdownString]) -> None: + self.obj = obj + self.escape = escape + + def __getitem__(self, item: Any) -> Self: + return self.__class__(self.obj[item], self.escape) + + def __str__(self) -> str: + return str(self.escape(self.obj)) + + def __repr__(self) -> str: + return str(self.escape(repr(self.obj))) + + def __int__(self) -> int: + return int(self.obj) + + def __float__(self) -> float: + return float(self.obj) + + +def _escape_argspec( + obj: _ListOrDict, iterable: Iterable[Any], escape: Callable[[Any], MarkdownString] +) -> _ListOrDict: + """Escape all arguments.""" + for key, value in iterable: + if isinstance(value, str) or hasattr(value, '__markdown__'): + obj[key] = escape(value) + + return obj + + +def _simple_escaping_wrapper( + func: Callable[Concatenate[str, _P], str] +) -> Callable[Concatenate[MarkdownString, _P], MarkdownString]: + @wraps(func) + def wrapped( + self: MarkdownString, *args: _P.args, **kwargs: _P.kwargs + ) -> MarkdownString: + arg_list = _escape_argspec(list(args), enumerate(args), self.escape) + _escape_argspec(kwargs, kwargs.items(), self.escape) + return self.__class__(func(self, *arg_list, **kwargs)) + + return wrapped + + +class MarkdownString(str): + """Markdown string, implements a __markdown__ method.""" + + __slots__ = () + + def __new__( + cls, base: Any = '', encoding: str | None = None, errors: str = 'strict' + ) -> MarkdownString: + if hasattr(base, '__markdown__'): + base = base.__markdown__() + + if encoding is None: + return super().__new__(cls, base) + + return super().__new__(cls, base, encoding, errors) + + def __markdown__(self) -> Self: + """Return a markdown embed-compatible string.""" + return self + + def __markdown_format__(self, format_spec: str) -> Self: + if format_spec: + # MarkdownString cannot support format_spec because any manipulation may + # remove an escape char, causing downstream damage with unwanted formatting + raise ValueError("Unsupported format specification for MarkdownString.") + + return self + + def unescape(self) -> str: + """Unescape the string.""" + return markdown_unescape_re.sub(r'\1', str(self)) + + @classmethod + def escape(cls, text: str | HasMarkdown, silent: bool = True) -> Self: + """Escape a string, for internal use only. Use :func:`markdown_escape`.""" + if silent and text is None: + return cls('') # type: ignore[unreachable] + if hasattr(text, '__markdown__'): + return cls(text.__markdown__()) + return cls(markdown_escape_re.sub(r'\\\1', text)) + + # These additional methods are borrowed from the implementation in markupsafe + + def __add__(self, other: str | HasMarkdown) -> Self: + if isinstance(other, str) or hasattr(other, '__markdown__'): + return self.__class__(super().__add__(self.escape(other))) + + return NotImplemented + + def __radd__(self, other: str | HasMarkdown) -> Self: + if isinstance(other, str) or hasattr(other, '__markdown__'): + return self.escape(other).__add__(self) + + return NotImplemented + + def __mul__(self, num: SupportsIndex) -> Self: + if isinstance(num, int): + return self.__class__(super().__mul__(num)) + + return NotImplemented + + __rmul__ = __mul__ + + def __mod__(self, arg: Any) -> Self: + """Apply legacy `str % arg(s)` formatting.""" + if isinstance(arg, tuple): + # a tuple of arguments, each wrapped + arg = tuple(_MarkdownEscapeHelper(x, self.escape) for x in arg) + elif hasattr(type(arg), '__getitem__') and not isinstance(arg, str): + # a mapping of arguments, wrapped + arg = _MarkdownEscapeHelper(arg, self.escape) + else: + # a single argument, wrapped with the helper and a tuple + arg = (_MarkdownEscapeHelper(arg, self.escape),) + + return self.__class__(super().__mod__(arg)) + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({super().__repr__()})' + + def join(self, iterable: Iterable[str | HasMarkdown]) -> Self: + return self.__class__(super().join(map(self.escape, iterable))) + + join.__doc__ = str.join.__doc__ + + def split( # type: ignore[override] + self, sep: str | None = None, maxsplit: SupportsIndex = -1 + ) -> list[Self]: + return [self.__class__(v) for v in super().split(sep, maxsplit)] + + split.__doc__ = str.split.__doc__ + + def rsplit( # type: ignore[override] + self, sep: str | None = None, maxsplit: SupportsIndex = -1 + ) -> list[Self]: + return [self.__class__(v) for v in super().rsplit(sep, maxsplit)] + + rsplit.__doc__ = str.rsplit.__doc__ + + def splitlines( # type: ignore[override] + self, keepends: bool = False + ) -> list[Self]: + return [self.__class__(v) for v in super().splitlines(keepends)] + + splitlines.__doc__ = str.splitlines.__doc__ + + __getitem__ = _simple_escaping_wrapper(str.__getitem__) # type: ignore[assignment] + capitalize = _simple_escaping_wrapper(str.capitalize) # type: ignore[assignment] + title = _simple_escaping_wrapper(str.title) # type: ignore[assignment] + lower = _simple_escaping_wrapper(str.lower) # type: ignore[assignment] + upper = _simple_escaping_wrapper(str.upper) # type: ignore[assignment] + replace = _simple_escaping_wrapper(str.replace) # type: ignore[assignment] + ljust = _simple_escaping_wrapper(str.ljust) # type: ignore[assignment] + rjust = _simple_escaping_wrapper(str.rjust) # type: ignore[assignment] + lstrip = _simple_escaping_wrapper(str.lstrip) # type: ignore[assignment] + rstrip = _simple_escaping_wrapper(str.rstrip) # type: ignore[assignment] + center = _simple_escaping_wrapper(str.center) # type: ignore[assignment] + strip = _simple_escaping_wrapper(str.strip) # type: ignore[assignment] + translate = _simple_escaping_wrapper(str.translate) # type: ignore[assignment] + expandtabs = _simple_escaping_wrapper(str.expandtabs) # type: ignore[assignment] + swapcase = _simple_escaping_wrapper(str.swapcase) # type: ignore[assignment] + zfill = _simple_escaping_wrapper(str.zfill) # type: ignore[assignment] + casefold = _simple_escaping_wrapper(str.casefold) # type: ignore[assignment] + + removeprefix = _simple_escaping_wrapper( # type: ignore[assignment] + str.removeprefix + ) + removesuffix = _simple_escaping_wrapper( # type: ignore[assignment] + str.removesuffix + ) + + def partition(self, sep: str) -> tuple[Self, Self, Self]: + left, sep, right = super().partition(self.escape(sep)) + cls = self.__class__ + return cls(left), cls(sep), cls(right) + + partition.__doc__ = str.partition.__doc__ + + def rpartition(self, sep: str) -> tuple[Self, Self, Self]: + left, sep, right = super().rpartition(self.escape(sep)) + cls = self.__class__ + return cls(left), cls(sep), cls(right) + + rpartition.__doc__ = str.rpartition.__doc__ + + def format(self, *args: Any, **kwargs: Any) -> Self: # noqa: A003 + formatter = _MarkdownEscapeFormatter(self.escape) + return self.__class__(formatter.vformat(self, args, kwargs)) + + format.__doc__ = str.format.__doc__ + + # pylint: disable=redefined-builtin + def format_map( + self, map: Mapping[str, Any] # type: ignore[override] # noqa: A002 + ) -> Self: + formatter = _MarkdownEscapeFormatter(self.escape) + return self.__class__(formatter.vformat(self, (), map)) + + format_map.__doc__ = str.format_map.__doc__ + + +def markdown_escape(text: str) -> MarkdownString: + """ + Escape all Markdown formatting characters and strip whitespace at ends. + + As per the CommonMark spec, all ASCII punctuation can be escaped with a backslash + and compliant parsers will then render the punctuation mark as a literal character. + However, escaping any other character will cause the backslash to be rendered. This + escaper therefore targets only ASCII punctuation characters listed in the spec. + + Edge whitespace is significant in Markdown: + + * Four spaces at the start will initiate a code block + * Two spaces at the end will cause a line-break in non-GFM Markdown + + The space and tab characters cannot be escaped, and replacing spaces with   is + not suitable because non-breaking spaces affect HTML rendering, specifically the + CSS ``white-space: normal`` sequence collapsing behaviour. Since there is no way to + predict adjacent whitespace when this escaped variable is placed in a Markdown + document, it is safest to strip all edge whitespace. + + ..note:: + This function strips edge whitespace and calls :meth:`MarkdownString.escape`, + and should be preferred over calling :meth:`MarkdownString.escape` directly. + That classmethod is internal to :class:`MarkdownString`. + + :returns: Escaped text as an instance of :class:`MarkdownString`, to avoid + double-escaping + """ + return MarkdownString.escape(text.strip()) diff --git a/funnel/utils/markdown/mdit_plugins/__init__.py b/funnel/utils/markdown/mdit_plugins/__init__.py index c03fb4f79..378ac9ad4 100644 --- a/funnel/utils/markdown/mdit_plugins/__init__.py +++ b/funnel/utils/markdown/mdit_plugins/__init__.py @@ -1,13 +1,15 @@ """Plugins for markdown-it-py.""" # flake8: noqa +from .abbr import * from .block_code_ext import * from .del_tag import * from .embeds import * from .footnote_ext import * +from .heading_anchors_fix import * from .ins_tag import * from .mark_tag import * +from .multimd_tables import * from .sub_tag import * from .sup_tag import * from .toc import * -from .multimd_tables import * \ No newline at end of file diff --git a/funnel/utils/markdown/mdit_plugins/abbr.py b/funnel/utils/markdown/mdit_plugins/abbr.py new file mode 100644 index 000000000..49a81a161 --- /dev/null +++ b/funnel/utils/markdown/mdit_plugins/abbr.py @@ -0,0 +1,145 @@ +""" +Markdown-it-py plugin to introduce markup for defined abbreviations. + +Ported from javascript plugin markdown-it-abbr. +""" + +from __future__ import annotations + +import re + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock +from markdown_it.rules_core import StateCore +from markdown_it.token import Token + +__all__ = ['abbr_plugin'] + +abbr_def_re = re.compile(r'^\s*\*\[(.+?)\]:(.+)$') + + +def abbr_def(state: StateBlock, start_line: int, end_line: int, silent: bool) -> bool: + """Store abbreviation definitions in env and remove them from content.""" + pos = state.bMarks[start_line] + state.tShift[start_line] + maximum = state.eMarks[start_line] + + if pos + 2 >= maximum: + return False + + line = state.src[pos:maximum] + + if not line.startswith('*['): + return False + + result = abbr_def_re.match(line) + + if result is None: + return False + + if silent: + return True + + # Extract label and title and store it in state.env + + label = result.group(1).replace('\\', '') + title = result.group(2).strip() + + if len(label) == 0 or len(title) == 0: + return False + + if 'abbr' not in state.env: + state.env['abbr'] = {} + + if label not in state.env['abbr']: + state.env['abbr'][label] = title + + state.line = start_line + 1 + return True + + +def abbr_replace(state: StateCore) -> None: + """Tokenizes and tags defined abbreviations in content.""" + block_tokens = state.tokens + + if 'abbr' not in state.env: + return + + labels_re_str = '|'.join( + [state.md.utils.escapeRE(k) for k in sorted(state.env['abbr'].keys(), key=len)] + ) + + simple_re = re.compile('(?:' + labels_re_str + ')') + + match_re_str = r'(^|\W)(' + labels_re_str + r')($|\W)' + + match_re = re.compile(match_re_str) + + block_token_index, block_tokens_length = 0, len(block_tokens) + while block_token_index < block_tokens_length: + block_token = block_tokens[block_token_index] + if block_token.type != 'inline': + block_token_index += 1 + continue + tokens = block_token.children + + token_index = len(tokens) - 1 # type: ignore[arg-type] + while token_index >= 0: + current_token = tokens[token_index] # type: ignore[index] + if current_token.type != 'text': + token_index -= 1 + continue + + current_text = current_token.content + + nodes = [] + + if simple_re.search(current_text) is None: + token_index -= 1 + continue + + next_pos = 0 + for matches in match_re.finditer(current_text): + prefix, match = matches.groups()[:2] + prefix_indices, suffix_indices = matches.regs[1:4:2] + + if prefix != '': + token = Token('text', '', 0) + token.content = current_text[next_pos : prefix_indices[1]] + nodes.append(token) + + token = Token('abbr_open', 'abbr', 1) + token.attrs['title'] = state.env['abbr'][match] + nodes.append(token) + + token = Token('text', '', 0) + token.content = match + nodes.append(token) + + token = Token('abbr_close', 'abbr', -1) + nodes.append(token) + + next_pos = suffix_indices[0] + + if len(nodes) == 0: + token_index -= 1 + continue + + if next_pos < len(current_text): + token = Token('text', '', 0) + token.content = current_text[next_pos:] + nodes.append(token) + + block_token.children = tokens = state.md.utils.arrayReplaceAt( + tokens, token_index, nodes + ) + token_index -= 1 + + block_token_index += 1 + + +def abbr_plugin(md: MarkdownIt) -> None: + """Enable Markdown plugin for abbreviations.""" + md.block.ruler.before( + 'reference', 'abbr_def', abbr_def, {'alt': ['paragraph', 'reference']} + ) + md.core.ruler.after('linkify', 'abbr_replace', abbr_replace) diff --git a/funnel/utils/markdown/mdit_plugins/embeds.py b/funnel/utils/markdown/mdit_plugins/embeds.py index 1024fde24..95de85600 100644 --- a/funnel/utils/markdown/mdit_plugins/embeds.py +++ b/funnel/utils/markdown/mdit_plugins/embeds.py @@ -7,9 +7,9 @@ from __future__ import annotations +import re from collections.abc import MutableMapping, Sequence from math import floor -import re from markdown_it import MarkdownIt from markdown_it.common.utils import charCodeAt @@ -61,14 +61,13 @@ def render( def embeds_func( state: StateBlock, start_line: int, end_line: int, silent: bool ) -> bool: - auto_closed = False start = state.bMarks[start_line] + state.tShift[start_line] maximum = state.eMarks[start_line] # Check out the first character quickly, # this should filter out most of non-containers - if marker_char != state.srcCharCode[start]: + if marker_char != ord(state.src[start]): return False # Check out the rest of the marker string @@ -116,7 +115,7 @@ def embeds_func( # test break - if marker_char != state.srcCharCode[start]: + if marker_char != ord(state.src[start]): continue if state.sCount[next_line] - state.blkIndent >= 4: diff --git a/funnel/utils/markdown/mdit_plugins/heading_anchors_fix.py b/funnel/utils/markdown/mdit_plugins/heading_anchors_fix.py new file mode 100644 index 000000000..be19ed338 --- /dev/null +++ b/funnel/utils/markdown/mdit_plugins/heading_anchors_fix.py @@ -0,0 +1,45 @@ +"""MDIT plugin to modify the token stream output of mdit-py-plugin heading-anchors.""" + +from __future__ import annotations + +from markdown_it import MarkdownIt +from markdown_it.rules_core import StateCore + +__all__ = ['heading_anchors_fix_plugin'] + + +def heading_anchors_fix(state: StateCore) -> None: + prev_token = None + + for token in state.tokens: + if prev_token is None: + prev_token = token + continue + if token.type == 'inline' and prev_token.type == 'heading_open': # type: ignore[unreachable] + tree = token.children + header_anchor_index = 0 + for inline_token in tree: + if ( + inline_token.type == 'link_open' + and inline_token.attrGet('class') == 'header-anchor' + ): + break + header_anchor_index += 1 + if header_anchor_index < len(tree): + popped = tree.pop(header_anchor_index) + tree.insert(0, popped) + anchor_index = 1 + while anchor_index < len(tree) - 1: + node = tree[anchor_index] + if node.type in ['link_open', 'link_close']: + tree.pop(anchor_index) + else: + anchor_index += 1 + tree[0].attrs.pop('class') + tree.pop(len(tree) - 2) + prev_token = token + + +def heading_anchors_fix_plugin(md: MarkdownIt, **opts) -> None: + if 'anchor' in md.get_active_rules()['core']: + md.core.ruler.after('anchor', 'heading_anchors_fix', heading_anchors_fix) diff --git a/funnel/utils/markdown/mdit_plugins/ins_tag.py b/funnel/utils/markdown/mdit_plugins/ins_tag.py index 8a71fb63c..f4087c7d2 100644 --- a/funnel/utils/markdown/mdit_plugins/ins_tag.py +++ b/funnel/utils/markdown/mdit_plugins/ins_tag.py @@ -7,7 +7,6 @@ from __future__ import annotations from collections.abc import MutableMapping, Sequence -from typing import List from markdown_it import MarkdownIt from markdown_it.renderer import OptionsDict, RendererHTML @@ -17,19 +16,18 @@ __all__ = ['ins_plugin'] -PLUS_CHAR = 0x2B # ASCII value for `+` +PLUS_CHAR = '+' def tokenize(state: StateInline, silent: bool) -> bool: """Insert each marker as a separate text token, and add it to delimiter list.""" start = state.pos - marker = state.srcCharCode[start] - ch = chr(marker) + ch = state.src[start] if silent: return False - if marker != PLUS_CHAR: + if ch != PLUS_CHAR: return False scanned = state.scanDelims(state.pos, True) @@ -50,9 +48,8 @@ def tokenize(state: StateInline, silent: bool) -> bool: token.content = ch + ch state.delimiters.append( Delimiter( - marker=marker, + marker=ord(ch), length=0, # disable "rule of 3" length checks meant for emphasis - jump=i // 2, # for `++` 1 marker = 2 characters token=len(state.tokens) - 1, end=-1, open=scanned.can_open, @@ -65,13 +62,13 @@ def tokenize(state: StateInline, silent: bool) -> bool: return True -def _post_process(state: StateInline, delimiters: List[Delimiter]) -> None: +def _post_process(state: StateInline, delimiters: list[Delimiter]) -> None: lone_markers = [] maximum = len(delimiters) for i in range(0, maximum): start_delim = delimiters[i] - if start_delim.marker != PLUS_CHAR: + if start_delim.marker != ord(PLUS_CHAR): i += 1 continue @@ -85,19 +82,19 @@ def _post_process(state: StateInline, delimiters: List[Delimiter]) -> None: token.type = 'ins_open' token.tag = 'ins' token.nesting = 1 - token.markup = '++' + token.markup = PLUS_CHAR * 2 token.content = '' token = state.tokens[end_delim.token] token.type = 'ins_close' token.tag = 'ins' token.nesting = -1 - token.markup = '++' + token.markup = PLUS_CHAR * 2 token.content = '' end_token = state.tokens[end_delim.token - 1] - if end_token.type == 'text' and end_token == '+': # nosec + if end_token.type == 'text' and end_token == PLUS_CHAR: # nosec lone_markers.append(end_delim.token - 1) # If a marker sequence has an odd number of characters, it's split diff --git a/funnel/utils/markdown/mdit_plugins/mark_tag.py b/funnel/utils/markdown/mdit_plugins/mark_tag.py index 876e1f0d7..f2d2fb63a 100644 --- a/funnel/utils/markdown/mdit_plugins/mark_tag.py +++ b/funnel/utils/markdown/mdit_plugins/mark_tag.py @@ -7,7 +7,6 @@ from __future__ import annotations from collections.abc import MutableMapping, Sequence -from typing import List from markdown_it import MarkdownIt from markdown_it.renderer import OptionsDict, RendererHTML @@ -17,19 +16,18 @@ __all__ = ['mark_plugin'] -EQUALS_CHAR = 0x3D # ASCII value for `=` +EQUALS_CHAR = '=' def tokenize(state: StateInline, silent: bool) -> bool: """Insert each marker as a separate text token, and add it to delimiter list.""" start = state.pos - marker = state.srcCharCode[start] - ch = chr(marker) + ch = state.src[start] if silent: return False - if marker != EQUALS_CHAR: + if ch != EQUALS_CHAR: return False scanned = state.scanDelims(state.pos, True) @@ -50,9 +48,8 @@ def tokenize(state: StateInline, silent: bool) -> bool: token.content = ch + ch state.delimiters.append( Delimiter( - marker=marker, + marker=ord(ch), length=0, # disable "rule of 3" length checks meant for emphasis - jump=i // 2, # for `==` 1 marker = 2 characters token=len(state.tokens) - 1, end=-1, open=scanned.can_open, @@ -65,13 +62,13 @@ def tokenize(state: StateInline, silent: bool) -> bool: return True -def _post_process(state: StateInline, delimiters: List[Delimiter]) -> None: +def _post_process(state: StateInline, delimiters: list[Delimiter]) -> None: lone_markers = [] maximum = len(delimiters) for i in range(0, maximum): start_delim = delimiters[i] - if start_delim.marker != EQUALS_CHAR: + if start_delim.marker != ord(EQUALS_CHAR): i += 1 continue @@ -85,19 +82,19 @@ def _post_process(state: StateInline, delimiters: List[Delimiter]) -> None: token.type = 'mark_open' token.tag = 'mark' token.nesting = 1 - token.markup = '==' + token.markup = EQUALS_CHAR * 2 token.content = '' token = state.tokens[end_delim.token] token.type = 'mark_close' token.tag = 'mark' token.nesting = -1 - token.markup = '==' + token.markup = EQUALS_CHAR * 2 token.content = '' end_token = state.tokens[end_delim.token - 1] - if end_token.type == 'text' and end_token == '=': # nosec + if end_token.type == 'text' and end_token == EQUALS_CHAR: # nosec lone_markers.append(end_delim.token - 1) # If a marker sequence has an odd number of characters, it's split diff --git a/funnel/utils/markdown/mdit_plugins/sub_tag.py b/funnel/utils/markdown/mdit_plugins/sub_tag.py index bf19b04c9..c70823eeb 100644 --- a/funnel/utils/markdown/mdit_plugins/sub_tag.py +++ b/funnel/utils/markdown/mdit_plugins/sub_tag.py @@ -7,8 +7,8 @@ from __future__ import annotations -from collections.abc import MutableMapping, Sequence import re +from collections.abc import MutableMapping, Sequence from markdown_it import MarkdownIt from markdown_it.renderer import OptionsDict, RendererHTML @@ -17,7 +17,7 @@ __all__ = ['sub_plugin'] -TILDE_CHAR = 0x7E # ASCII value for `~` +TILDE_CHAR = '~' WHITESPACE_RE = re.compile(r'(^|[^\\])(\\\\)*\s') UNESCAPE_RE = re.compile(r'\\([ \\!"#$%&\'()*+,.\/:;<=>?@[\]^_`{|}~-])') @@ -25,14 +25,14 @@ def tokenize(state: StateInline, silent: bool) -> bool: start = state.pos - marker = state.srcCharCode[start] + ch = state.src[start] maximum = state.posMax found = False if silent: return False - if marker != TILDE_CHAR: + if ch != TILDE_CHAR: return False # Don't run any pairs in validation mode @@ -42,7 +42,7 @@ def tokenize(state: StateInline, silent: bool) -> bool: state.pos = start + 1 while state.pos < maximum: - if state.srcCharCode[state.pos] == TILDE_CHAR: + if state.src[state.pos] == TILDE_CHAR: found = True break state.md.inline.skipToken(state) @@ -63,13 +63,13 @@ def tokenize(state: StateInline, silent: bool) -> bool: # Earlier we checked "not silent", but this implementation does not need it token = state.push('sub_open', 'sub', 1) - token.markup = '~' + token.markup = TILDE_CHAR token = state.push('text', '', 0) token.content = UNESCAPE_RE.sub('$1', content) token = state.push('sub_close', 'sub', -1) - token.markup = '~' + token.markup = TILDE_CHAR state.pos = state.posMax + 1 state.posMax = maximum diff --git a/funnel/utils/markdown/mdit_plugins/sup_tag.py b/funnel/utils/markdown/mdit_plugins/sup_tag.py index b285eaf21..767892115 100644 --- a/funnel/utils/markdown/mdit_plugins/sup_tag.py +++ b/funnel/utils/markdown/mdit_plugins/sup_tag.py @@ -7,8 +7,8 @@ from __future__ import annotations -from collections.abc import MutableMapping, Sequence import re +from collections.abc import MutableMapping, Sequence from markdown_it import MarkdownIt from markdown_it.renderer import OptionsDict, RendererHTML @@ -17,7 +17,7 @@ __all__ = ['sup_plugin'] -CARET_CHAR = 0x5E # ASCII value for `^` +CARET_CHAR = '^' WHITESPACE_RE = re.compile(r'(^|[^\\])(\\\\)*\s') UNESCAPE_RE = re.compile(r'\\([ \\!"#$%&\'()*+,.\/:;<=>?@[\]^_`{|}~-])') @@ -25,14 +25,14 @@ def tokenize(state: StateInline, silent: bool) -> bool: start = state.pos - marker = state.srcCharCode[start] + ch = state.src[start] maximum = state.posMax found = False if silent: return False - if marker != CARET_CHAR: + if ch != CARET_CHAR: return False # Don't run any pairs in validation mode @@ -42,7 +42,7 @@ def tokenize(state: StateInline, silent: bool) -> bool: state.pos = start + 1 while state.pos < maximum: - if state.srcCharCode[state.pos] == CARET_CHAR: + if state.src[state.pos] == CARET_CHAR: found = True break state.md.inline.skipToken(state) @@ -63,13 +63,13 @@ def tokenize(state: StateInline, silent: bool) -> bool: # Earlier we checked "not silent", but this implementation does not need it token = state.push('sup_open', 'sup', 1) - token.markup = '^' + token.markup = CARET_CHAR token = state.push('text', '', 0) token.content = UNESCAPE_RE.sub('$1', content) token = state.push('sup_close', 'sup', -1) - token.markup = '^' + token.markup = CARET_CHAR state.pos = state.posMax + 1 state.posMax = maximum diff --git a/funnel/utils/markdown/mdit_plugins/toc.py b/funnel/utils/markdown/mdit_plugins/toc.py index 2bd247789..0fa35eff2 100644 --- a/funnel/utils/markdown/mdit_plugins/toc.py +++ b/funnel/utils/markdown/mdit_plugins/toc.py @@ -12,24 +12,24 @@ from __future__ import annotations +import re from collections.abc import MutableMapping, Sequence from functools import reduce -from typing import Dict, List, Optional -import re +from typing_extensions import TypedDict from markdown_it import MarkdownIt from markdown_it.renderer import OptionsDict, RendererHTML +from markdown_it.rules_core import StateCore from markdown_it.rules_inline import StateInline from markdown_it.token import Token -from typing_extensions import TypedDict from coaster.utils import make_name __all__ = ['toc_plugin'] -SQUARE_BRACKET_OPEN_CHAR = 0x5B # ASCII value for `[` +SQUARE_BRACKET_OPEN_CHAR = '[' -defaults: Dict = { +defaults: dict = { 'include_level': [1, 2, 3, 4, 5, 6], 'container_class': 'table-of-contents', 'slugify': lambda x, **options: 'h:' + make_name(x, **options), @@ -44,18 +44,18 @@ class TocItem(TypedDict): level: int - text: Optional[str] - anchor: Optional[str] - children: List[TocItem] - parent: Optional[TocItem] + text: str | None + anchor: str | None + children: list[TocItem] + parent: TocItem | None def find_elements( - levels: List[int], tokens: List[Token], options: Dict -) -> List[TocItem]: + levels: list[int], tokens: list[Token], options: dict +) -> list[TocItem]: """Find all headline items for the defined levels in a Markdown document.""" headings = [] - current_heading: Optional[TocItem] = None + current_heading: TocItem | None = None for token in tokens: if token.type == 'heading_open': @@ -88,7 +88,7 @@ def find_elements( return headings -def find_existing_id_attr(token: Token) -> Optional[str]: +def find_existing_id_attr(token: Token) -> str | None: """ Find an existing id attr on a token. @@ -101,13 +101,13 @@ def find_existing_id_attr(token: Token) -> Optional[str]: return None -def get_min_level(items: List[TocItem]) -> int: +def get_min_level(items: list[TocItem]) -> int: """Get minimum headline level so that the TOC is nested correctly.""" return min(item['level'] for item in items) def add_list_item( - level: int, text: Optional[str], anchor: Optional[str], root_node: TocItem + level: int, text: str | None, anchor: str | None, root_node: TocItem ) -> TocItem: """Create a TOCItem.""" item: TocItem = { @@ -121,7 +121,7 @@ def add_list_item( return item -def items_to_tree(items: List[TocItem]) -> TocItem: +def items_to_tree(items: list[TocItem]) -> TocItem: """Turn list of headline items into a nested tree object representing the TOC.""" # Create a root node with no text that holds the entire TOC. # This won't be rendered, but only its children. @@ -164,7 +164,7 @@ def items_to_tree(items: List[TocItem]) -> TocItem: return toc -def toc_item_to_html(item: TocItem, options: Dict, md: MarkdownIt) -> str: +def toc_item_to_html(item: TocItem, options: dict, md: MarkdownIt) -> str: """Recursively turns a nested tree of tocItems to HTML.""" html = f"<{options['list_type']}>" for child in item['children']: @@ -197,7 +197,7 @@ def toc_plugin(md: MarkdownIt, **opts) -> None: def toc(state: StateInline, silent: bool) -> bool: # Reject if the token does not start with [ - if state.srcCharCode[state.pos] != SQUARE_BRACKET_OPEN_CHAR: + if state.src[state.pos] != SQUARE_BRACKET_OPEN_CHAR: return False if silent: return False @@ -253,7 +253,7 @@ def toc_body( html = toc_item_to_html(toc, opts, md) return html - def grab_state(state: StateInline): + def grab_state(state: StateCore): state.env['gstate'] = state md.core.ruler.push('grab_state', grab_state) diff --git a/funnel/utils/markdown/tabs.py b/funnel/utils/markdown/tabs.py new file mode 100644 index 000000000..ab77c0a23 --- /dev/null +++ b/funnel/utils/markdown/tabs.py @@ -0,0 +1,248 @@ +"""MDIT renderer and helpers for tabs.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import reduce +from typing import Any, ClassVar + +from markdown_it.token import Token + +__all__ = ['render_tab'] + + +def render_tab(self, tokens: list[Token], idx, _options, env): + if 'manager' not in env: + env['manager'] = TabsManager(tokens) + + node = env['manager'].index(idx) + if node is None: + return '' + if tokens[idx].nesting == 1: + return node.html_open + return node.html_close + + +@dataclass +class TabsetNode: + start: int + parent: TabNode | None = None + children: list[TabNode] = field(default_factory=list) + _html_tabs: ClassVar[str] = '
        {items_html}
      ' + html_close: ClassVar[str] = '
      ' + _tabset_id: str = '' + + def flatten(self) -> list[TabNode]: + tabs = self.children + for tab in self.children: + for tabset in tab.children: + tabs = tabs + tabset.flatten() + return tabs + + @property + def html_open(self): + items_html = ''.join([item.html_tab_item for item in self.children]) + return ( + f'
      ' + + self._html_tabs.format(items_html=items_html) + ) + + @property + def tabset_id(self) -> str: + return 'md-tabset-' + self._tabset_id + + @tabset_id.setter + def tabset_id(self, value) -> None: + self._tabset_id = value + + +@dataclass +class TabNode: + start: int + end: int + info: str + key: str + parent: TabsetNode + _tab_id: str = '' + children: list[TabsetNode] = field(default_factory=list) + _opening: ClassVar[str] = ( + '
      ' + ) + _closing: ClassVar[str] = '
      ' + _item_html: ClassVar[str] = ( + '
    • ' + + '{title}
    • ' + ) + + def _class_attr(self, classes=None): + if classes is None: + classes = [] + classes = classes + self._active_class + if self.title == '': + classes.append('no-title') + return f' class="{" ".join(classes)}"' if len(classes) > 0 else '' + + @property + def _item_aria(self): + if self.is_first: + return ' tabindex="0" aria-selected="true"' + return ' tabindex="-1" aria-selected="false"' + + def flatten(self) -> list[TabsetNode]: + tabsets = self.children + for tabset in self.children: + for tab in tabset.children: + tabsets = tabsets + tab.flatten() + return tabsets + + @property + def _active_class(self): + return ['md-tab-active'] if self.is_first else [] + + @property + def title(self): + tab_title = ' '.join(self.info.strip().split()[1:]) + return tab_title or 'Tab ' + str(self.parent.children.index(self) + 1) + + @property + def tab_id(self) -> str: + return 'md-tab-' + self._tab_id + + @tab_id.setter + def tab_id(self, value) -> None: + self._tab_id = value + + @property + def is_first(self) -> bool: + return self.start == self.parent.start + + @property + def is_last(self) -> bool: + return self == self.parent.children[-1] + + @property + def html_open(self) -> str: + opening = self._opening.format( + tab_id=self.tab_id, + class_attr=self._class_attr(), + ) + if self.is_first: + opening = self.parent.html_open + opening + return opening + + @property + def html_close(self) -> str: + return self._closing + (self.parent.html_close if self.is_last else '') + + @property + def html_tab_item(self): + return self._item_html.format( + tab_id=self.tab_id, + tabset_id=self.parent.tabset_id, + title=self.title, + class_attr=self._class_attr(), + accessibility=self._item_aria, + ) + + +class TabsManager: + tabsets: list[TabsetNode] + _index: dict[int, TabNode] + + def __init__(self, tokens: list[Token]) -> None: + tab_tokens = self._get_tab_tokens(tokens) + self.tabsets: list[TabsetNode] = self.make(tab_tokens) + self._index = {} + self.index() + + def make( + self, tab_tokens: list[dict[str, Any]], parent: TabNode | None = None + ) -> list[TabsetNode]: + open_index, close_index = 0, len(tab_tokens) - 1 + nodes: list[TabNode] = [] + tabsets: list[TabsetNode] = [] + previous: TabNode | None = None + while True: + pairs = self._tab_token_pair( + tab_tokens[open_index : close_index + 1], start=open_index + ) + if pairs is None: + break + open_index, close_index = pairs + if ( + previous is None + or previous.key != tab_tokens[open_index]['key'] + or previous.end + 1 != tab_tokens[open_index]['index'] + ): + tabset = TabsetNode(tab_tokens[open_index]['index'], parent) + tabsets.append(tabset) + node = TabNode( + start=tab_tokens[open_index]['index'], + end=tab_tokens[close_index]['index'], + key=tab_tokens[open_index]['key'], + info=tab_tokens[open_index]['info'], + parent=tabset, + ) + nodes.append(node) + tabset.children.append(node) + node.parent = tabset + node.children = self.make( + tab_tokens[open_index + 1 : close_index], parent=node + ) + if close_index + 1 == len(tab_tokens): + break + open_index, close_index = close_index + 1, len(tab_tokens) - 1 + previous = node + + return tabsets + + def _get_tab_tokens(self, tokens: list[Token]) -> list[dict[str, Any]]: + return [ + { + 'index': i, + 'nesting': token.nesting, + 'info': token.info, + 'key': '-'.join([token.markup, str(token.level)]), + } + for i, token in enumerate(tokens) + if token.type in ('container_tab_open', 'container_tab_close') + ] + + def _tab_token_pair( + self, tab_tokens: list[dict[str, Any]], start=0 + ) -> tuple[int, int] | None: + i = 1 + while i < len(tab_tokens): + if ( + tab_tokens[i]['nesting'] == -1 + and tab_tokens[0]['key'] == tab_tokens[i]['key'] + ): + break + i += 1 + if i >= len(tab_tokens): + return None + return (start, start + i) + + def index(self, start: int | None = None) -> TabNode | None: + if start is not None: + try: + return self._index[start] + except KeyError: + return None + + tabsets: list[TabsetNode] = [] + for tabset in self.tabsets: + tabsets.append(tabset) + for tab in tabset.children: + tabsets = tabsets + tab.flatten() + for i, tabset in enumerate(tabsets): + tabset.tabset_id = str(i + 1) + tabs: list[TabNode] = reduce( + lambda tablist, tabset: tablist + tabset.flatten(), self.tabsets, [] + ) + for i, tab in enumerate(tabs): + self._index[tab.start] = tab + self._index[tab.end] = tab + tab.tab_id = str(i + 1) + return None diff --git a/funnel/utils/misc.py b/funnel/utils/misc.py index 1f1cff684..99a3b36fd 100644 --- a/funnel/utils/misc.py +++ b/funnel/utils/misc.py @@ -2,16 +2,15 @@ from __future__ import annotations -from hashlib import blake2b -from typing import List, Optional, Union, overload import io import urllib.parse - -from flask import abort +from hashlib import blake2b +from typing import overload import phonenumbers import qrcode import qrcode.image.svg +from flask import abort __all__ = [ 'blake2b160_hex', @@ -45,7 +44,7 @@ def abort_null(text: None) -> None: ... -def abort_null(text: Optional[str]) -> Optional[str]: +def abort_null(text: str | None) -> str | None: """ Abort request if text contains null characters. @@ -57,7 +56,7 @@ def abort_null(text: Optional[str]) -> Optional[str]: def make_redirect_url( - url: str, use_fragment: bool = False, **params: Optional[Union[str, int]] + url: str, use_fragment: bool = False, **params: str | int | None ) -> str: """ Make an OAuth2 redirect URL. @@ -112,7 +111,7 @@ def mask_phone(phone: str) -> str: return f'{prefix}{middle}{suffix}' -def extract_twitter_handle(handle: str) -> Optional[str]: +def extract_twitter_handle(handle: str) -> str | None: """ Extract a twitter handle from a user input. @@ -149,7 +148,7 @@ def format_twitter_handle(handle: str) -> str: return f"@{handle}" if handle else "" -def split_name(fullname: str) -> List: +def split_name(fullname: str) -> list: """ Split a given fullname into a first name and remaining names. diff --git a/funnel/utils/mustache.py b/funnel/utils/mustache.py index 00e48c987..5de588abb 100644 --- a/funnel/utils/mustache.py +++ b/funnel/utils/mustache.py @@ -1,37 +1,84 @@ """Mustache templating support.""" -from copy import copy -from typing import Callable import functools import types - -from flask import escape as html_escape +from collections.abc import Callable +from copy import copy +from typing import TypeVar +from typing_extensions import ParamSpec from chevron import render +from markupsafe import Markup, escape as html_escape -from .markdown import markdown_escape +from .markdown import MarkdownString, markdown_escape __all__ = ['mustache_html', 'mustache_md'] -def _render_with_escape(name: str, escapefunc: Callable[[str], str]) -> render: - """Make a copy of Chevron's render function with a replacement HTML escaper.""" - _globals = copy(render.__globals__) - _globals['_html_escape'] = escapefunc +_P = ParamSpec('_P') +_T = TypeVar('_T', bound=str) + + +def _render_with_escape( + name: str, + renderer: Callable[_P, str], + escapefunc: Callable[[str], str], + recast: type[_T], + doc: str | None = None, +) -> Callable[_P, _T]: + """ + Make a copy of Chevron's render function with a replacement HTML escaper. + + Chevron does not allow the HTML escaper to be customized, so we construct a new + function using the same code, replacing the escaper in the globals. We also recast + Chevron's output to a custom sub-type of str like :class:`~markupsafe.Markup` or + :class:`~funnel.utils.markdown.escape.MarkdownString`. + + :param name: Name of the new function (readable as `func.__name__`) + :param renderer: Must be :func:`chevron.render` and must be explicitly passed for + mypy to recognise the function's parameters + :param escapefunc: Replacement escape function + :param recast: str subtype to recast Chevron's output to + :param doc: Optional replacement docstring + """ + _globals = copy(renderer.__globals__) + # Chevron tries `output += _html_escape(thing)`, which given Markup or + # MarkdownString will call `thing.__radd__(output)`, which will then escape the + # existing output. We must therefore recast the escaped string as a plain `str` + _globals['_html_escape'] = lambda text: str(escapefunc(text)) new_render = types.FunctionType( - render.__code__, + renderer.__code__, _globals, name=name, - argdefs=render.__defaults__, - closure=render.__closure__, + argdefs=renderer.__defaults__, + closure=renderer.__closure__, ) - new_render = functools.update_wrapper(new_render, render) + new_render = functools.update_wrapper(new_render, renderer) new_render.__module__ = __name__ - new_render.__kwdefaults__ = copy(render.__kwdefaults__) - return new_render + new_render.__kwdefaults__ = copy(renderer.__kwdefaults__) + new_render.__doc__ = renderer.__doc__ + + @functools.wraps(renderer) + def render_and_recast(*args: _P.args, **kwargs: _P.kwargs) -> _T: + # pylint: disable=not-callable + return recast(new_render(*args, **kwargs)) + + render_and_recast.__doc__ = doc if doc else renderer.__doc__ + return render_and_recast -mustache_html = _render_with_escape('mustache_html', html_escape) -mustache_md = _render_with_escape('mustache_md', markdown_escape) -# TODO: Add mustache_mdhtml for use with Markdown with HTML tags enabled +mustache_html = _render_with_escape( + 'mustache_html', + render, + html_escape, + Markup, + doc="Render a Mustache template in a HTML context.", +) +mustache_md = _render_with_escape( + 'mustache_md', + render, + markdown_escape, + MarkdownString, + doc="Render a Mustache template in a Markdown context.", +) diff --git a/funnel/views/account.py b/funnel/views/account.py index cd2a4e250..4a5776e24 100644 --- a/funnel/views/account.py +++ b/funnel/views/account.py @@ -2,23 +2,22 @@ from __future__ import annotations +from string import capwords from types import SimpleNamespace -from typing import TYPE_CHECKING, Dict, List, Optional, Union +from typing import TYPE_CHECKING +import user_agents from flask import ( - Markup, abort, current_app, - escape, flash, redirect, render_template, request, + session, url_for, ) - -import geoip2.errors -import user_agents +from markupsafe import Markup, escape from baseframe import _, forms from baseframe.forms import render_delete_sqla, render_form, render_message @@ -30,6 +29,7 @@ from ..forms import ( AccountDeleteForm, AccountForm, + EmailOtpForm, EmailPrimaryForm, LogoutForm, NewEmailAddressForm, @@ -42,18 +42,18 @@ supported_locales, timezone_identifiers, ) +from ..geoip import GeoIP2Error, geoip from ..models import ( + Account, + AccountEmail, + AccountEmailClaim, + AccountExternalId, + AccountMembership, AccountPasswordNotification, + AccountPhone, AuthClient, + LoginSession, Organization, - OrganizationMembership, - Profile, - User, - UserEmail, - UserEmailClaim, - UserExternalId, - UserPhone, - UserSession, db, sa, ) @@ -80,52 +80,52 @@ from .otp import OtpSession, OtpTimeoutError -@User.views() -def emails_sorted(obj: User) -> List[UserEmail]: +@Account.views() +def emails_sorted(obj: Account) -> list[AccountEmail]: """Return sorted list of email addresses for account page UI.""" primary = obj.primary_email items = sorted(obj.emails, key=lambda i: (i != primary, i.email or '')) return items -@User.views() -def phones_sorted(obj: User) -> List[UserPhone]: +@Account.views() +def phones_sorted(obj: Account) -> list[AccountPhone]: """Return sorted list of phone numbers for account page UI.""" primary = obj.primary_phone items = sorted(obj.phones, key=lambda i: (i != primary, i.phone or '')) return items -@User.views('locale') -def user_locale(obj: User) -> str: +@Account.views('locale') +def user_locale(obj: Account) -> str: """Name of user's locale, defaulting to locale identifier.""" locale = str(obj.locale) if obj.locale is not None else 'en' return supported_locales.get(locale, locale) -@User.views('timezone') -def user_timezone(obj: User) -> str: +@Account.views('timezone') +def user_timezone(obj: Account) -> str: """Human-friendly identifier for user's timezone, defaulting to timezone name.""" return timezone_identifiers.get( - str(obj.timezone) if obj.timezone else None, obj.timezone + str(obj.timezone) if obj.timezone else '', obj.timezone ) -@User.views() +@Account.views() def organizations_as_admin( - obj: User, + obj: Account, owner: bool = False, - limit: Optional[int] = None, + limit: int | None = None, order_by_grant: bool = False, -) -> List[RoleAccessProxy]: +) -> list[RoleAccessProxy]: """Return organizations that the user is an admin of.""" if owner: orgmems = obj.active_organization_owner_memberships else: orgmems = obj.active_organization_admin_memberships - orgmems = orgmems.join(Organization) + orgmems = orgmems.join(Account, AccountMembership.account) if order_by_grant: - orgmems = orgmems.order_by(OrganizationMembership.granted_at.desc()) + orgmems = orgmems.order_by(AccountMembership.granted_at.desc()) else: orgmems = orgmems.order_by(sa.func.lower(Organization.title)) @@ -136,19 +136,19 @@ def organizations_as_admin( return orgs -@User.views() +@Account.views() def organizations_as_owner( - obj: User, limit: Optional[int] = None, order_by_grant: bool = False -) -> List[RoleAccessProxy]: + obj: Account, limit: int | None = None, order_by_grant: bool = False +) -> list[RoleAccessProxy]: """Return organizations that the user is an owner of.""" return obj.views.organizations_as_admin( owner=True, limit=limit, order_by_grant=order_by_grant ) -@User.views() +@Account.views() def recent_organization_memberships( - obj: User, recent: int = 3, overflow: int = 4 + obj: Account, recent: int = 3, overflow: int = 4 ) -> SimpleNamespace: """ Return recent organizations for the user (by recently edited membership). @@ -173,10 +173,8 @@ def recent_organization_memberships( ) -@User.views('avatar_color_code', cached_property=True) -@Organization.views('avatar_color_code', cached_property=True) -@Profile.views('avatar_color_code', cached_property=True) -def avatar_color_code(obj: Union[User, Organization, Profile]) -> int: +@Account.views('avatar_color_code', cached_property=True) +def avatar_color_code(obj: Account) -> int: """Return a colour code for the user's autogenerated avatar image.""" # Return an int from 0 to avatar_color_count from the initials of the given string if obj.title: @@ -190,19 +188,19 @@ def avatar_color_code(obj: Union[User, Organization, Profile]) -> int: return total % avatar_color_count -@User.features('not_likely_throwaway', property=True) -def user_not_likely_throwaway(obj: User) -> bool: +@Account.features('not_likely_throwaway', property=True) +def user_not_likely_throwaway(obj: Account) -> bool: """ Confirm the user is not likely to be a throwaway account. - Current criteria: user must have a verified phone number, or user's profile must - be marked as verified. + Current criteria: user must have a verified phone number, or the account must be + marked as verified. """ - return bool(obj.phone) or (obj.profile is not None and obj.profile.is_verified) + return obj.is_verified or bool(obj.phone) -@UserSession.views('user_agent_details') -def user_agent_details(obj: UserSession) -> Dict[str, str]: +@LoginSession.views('user_agent_details') +def user_agent_details(obj: LoginSession) -> dict[str, str]: """Return a friendly identifier for the user's browser (HTTP user agent).""" ua = user_agents.parse(obj.user_agent) if ua.browser.family: @@ -239,32 +237,39 @@ def user_agent_details(obj: UserSession) -> Dict[str, str]: return {'browser': browser, 'os_device': os_device} -@UserSession.views('location') -def user_session_location(obj: UserSession) -> str: +@LoginSession.views('location') +def login_session_location(obj: LoginSession) -> str: """Return user's location and ISP as determined from their IP address.""" - if not app.geoip_city or not app.geoip_asn: + if obj.ipaddr == '127.0.0.1': + return _("This device") + if not geoip: return _("Unknown location") try: - city_lookup = app.geoip_city.city(obj.ipaddr) - asn_lookup = app.geoip_asn.asn(obj.ipaddr) - except geoip2.errors.GeoIP2Error: + city_lookup = geoip.city(obj.ipaddr) + asn_lookup = geoip.asn(obj.ipaddr) + except GeoIP2Error: return _("Unknown location") # ASN is not ISP, but GeoLite2 only has an ASN database. The ISP db is commercial. - return ( - ((city_lookup.city.name + ", ") if city_lookup.city.name else '') - + ( - (city_lookup.subdivisions.most_specific.iso_code + ", ") - if city_lookup.subdivisions.most_specific.iso_code - else '' + if city_lookup: + result = ( + ((city_lookup.city.name + ", ") if city_lookup.city.name else '') + + ( + (city_lookup.subdivisions.most_specific.iso_code + ", ") + if city_lookup.subdivisions.most_specific.iso_code + else '' + ) + + ((city_lookup.country.name + "; ") if city_lookup.country.name else '') ) - + ((city_lookup.country.name + "; ") if city_lookup.country.name else '') - + (asn_lookup.autonomous_system_organization or _("Unknown ISP")) - ) + else: + result = '' + if asn_lookup: + result += asn_lookup.autonomous_system_organization or _("Unknown ISP") + return result -@UserSession.views('login_service') -def user_session_login_service(obj: UserSession) -> Optional[str]: +@LoginSession.views('login_service') +def login_session_service(obj: LoginSession) -> str | None: """Return the login provider that was used to create the login session.""" if obj.login_service == 'otp': return _("OTP") @@ -279,11 +284,11 @@ class AccountView(ClassView): __decorators__ = [requires_login] - obj: User + obj: Account current_section = 'account' # needed for showing active tab SavedProjectForm = SavedProjectForm - def loader(self, **kwargs) -> User: + def loader(self, **kwargs) -> Account: """Return current user.""" return current_auth.user @@ -362,22 +367,22 @@ def edit(self) -> ReturnView: ) # FIXME: Don't modify db on GET. Autosubmit via JS and process on POST - @route('confirm//', endpoint='confirm_email') - def confirm_email(self, email_hash: str, secret: str) -> ReturnView: - """Confirm an email address using a verification link.""" + @route('confirm//', endpoint='confirm_email_legacy') + def confirm_email_legacy(self, email_hash: str, secret: str) -> ReturnView: + """Confirm an email address using a legacy verification link.""" try: - emailclaim = UserEmailClaim.get_by( + emailclaim = AccountEmailClaim.get_by( verification_code=secret, email_hash=email_hash ) except ValueError: # Possible when email_hash is invalid Base58 abort(404) if emailclaim is not None: emailclaim.email_address.mark_active() - if emailclaim.user == current_auth.user: - existing = UserEmail.get(email=emailclaim.email) + if emailclaim.account == current_auth.user: + existing = AccountEmail.get(email=emailclaim.email) if existing is not None: claimed_email = emailclaim.email - claimed_user = emailclaim.user + claimed_user = emailclaim.account db.session.delete(emailclaim) db.session.commit() if claimed_user != current_auth.user: @@ -403,13 +408,12 @@ def confirm_email(self, email_hash: str, secret: str) -> ReturnView: ), ) - useremail = emailclaim.user.add_email( + accountemail = emailclaim.account.add_email( emailclaim.email, - primary=not emailclaim.user.emails, - type=emailclaim.type, + primary=not emailclaim.account.emails, private=emailclaim.private, ) - for emailclaim in UserEmailClaim.all(useremail.email): + for emailclaim in AccountEmailClaim.all(accountemail.email): db.session.delete(emailclaim) db.session.commit() user_data_changed.send(current_auth.user, changes=['email']) @@ -421,8 +425,8 @@ def confirm_email(self, email_hash: str, secret: str) -> ReturnView: " Your email address {email} has now been" " verified" ).format( - fullname=escape(useremail.user.fullname), - email=escape(useremail.email), + fullname=escape(accountemail.account.title), + email=escape(accountemail.email), ) ), ) @@ -459,8 +463,8 @@ def change_password(self) -> ReturnView: # 1. Log out of the current session logout_internal() # 2. As a precaution, invalidate all of the user's active sessions - for user_session in user.active_user_sessions.all(): - user_session.revoke() + for login_session in user.active_login_sessions.all(): + login_session.revoke() # 3. Create a new session and continue without disrupting user experience login_internal(user, login_service='password') db.session.commit() @@ -485,27 +489,69 @@ def change_password(self) -> ReturnView: @route('email/new', methods=['GET', 'POST'], endpoint='add_email') def add_email(self) -> ReturnView: - """Add a new email address using a confirmation link (legacy, pre-OTP).""" + """Add a new email address using an OTP.""" form = NewEmailAddressForm(edit_user=current_auth.user) if form.validate_on_submit(): - useremail = UserEmailClaim.get_for( - user=current_auth.user, email=form.email.data + otp_session = OtpSession.make( + 'add-email', user=current_auth.user, anchor=None, email=form.email.data ) - if useremail is None: - useremail = UserEmailClaim( - user=current_auth.user, email=form.email.data, type=form.type.data + if otp_session.send(): + current_auth.user.main_notification_preferences.by_email = ( + form.enable_notifications.data ) - db.session.add(useremail) - send_email_verify_link(useremail) - db.session.commit() - flash(_("We sent you an email to confirm your address"), 'success') - user_data_changed.send(current_auth.user, changes=['email-claim']) - return render_redirect(url_for('account')) + return render_redirect(url_for('verify_email')) return render_form( form=form, title=_("Add an email address"), formid='email_add', - submit=_("Add email"), + submit=_("Verify email"), + ajax=False, + template='account_formlayout.html.jinja2', + ) + + @route('email/verify', methods=['GET', 'POST'], endpoint='verify_email') + def verify_email(self) -> ReturnView: + """Verify an email address with an OTP.""" + try: + otp_session = OtpSession.retrieve('add-email') + except OtpTimeoutError: + flash(_("This OTP has expired"), category='error') + return render_redirect(url_for('add_email')) + + form = EmailOtpForm(valid_otp=otp_session.otp) + if form.is_submitted(): + # Allow 5 guesses per 60 seconds + validate_rate_limit('account_email-otp', otp_session.token, 5, 60) + if form.validate_on_submit(): + OtpSession.delete() + if TYPE_CHECKING: + assert otp_session.email is not None # nosec B101 + existing = AccountEmail.get(otp_session.email) + if existing is None: + # This email address is available to claim. If there are no other email + # addresses in this account, this will be a primary + primary = not current_auth.user.emails + useremail = AccountEmail( + account=current_auth.user, email=otp_session.email + ) + useremail.primary = primary + db.session.add(useremail) + useremail.email_address.mark_active() + db.session.commit() + flash(_("Your email address has been verified"), 'success') + user_data_changed.send(current_auth.user, changes=['email']) + return render_redirect( + get_next_url(session=True, default=url_for('account')) + ) + # Already linked to another account, but we have verified the ownership, so + # proceed to merge account flow here + session['merge_buid'] = existing.user.buid + return render_redirect(url_for('account_merge'), 303) + return render_form( + form=form, + title=_("Verify email address"), + formid='email_verify', + submit=_("Verify"), ajax=False, template='account_formlayout.html.jinja2', ) @@ -515,16 +561,16 @@ def make_email_primary(self) -> ReturnView: """Mark an email address as primary.""" form = EmailPrimaryForm() if form.validate_on_submit(): - useremail = UserEmail.get_for( - user=current_auth.user, email_hash=form.email_hash.data + accountemail = AccountEmail.get_for( + account=current_auth.user, email_hash=form.email_hash.data ) - if useremail is not None: - if useremail.primary: + if accountemail is not None: + if accountemail.primary: flash(_("This is already your primary email address"), 'info') - elif useremail.email_address.is_blocked: + elif accountemail.email_address.is_blocked: flash(_("This email address has been blocked from use"), 'error') else: - current_auth.user.primary_email = useremail + current_auth.user.primary_email = accountemail db.session.commit() user_data_changed.send( current_auth.user, changes=['email-update-primary'] @@ -543,16 +589,16 @@ def make_phone_primary(self) -> ReturnView: """Mark a phone number as primary.""" form = PhonePrimaryForm() if form.validate_on_submit(): - userphone = UserPhone.get_for( - user=current_auth.user, phone_hash=form.phone_hash.data + accountphone = AccountPhone.get_for( + account=current_auth.user, phone_hash=form.phone_hash.data ) - if userphone is not None: - if userphone.primary: + if accountphone is not None: + if accountphone.primary: flash(_("This is already your primary phone number"), 'info') - elif userphone.phone_number.is_blocked: + elif accountphone.phone_number.is_blocked: flash(_("This phone number has been blocked from use"), 'error') else: - current_auth.user.primary_phone = userphone + current_auth.user.primary_phone = accountphone db.session.commit() user_data_changed.send( current_auth.user, changes=['phone-update-primary'] @@ -573,19 +619,21 @@ def make_phone_primary(self) -> ReturnView: ) def remove_email(self, email_hash: str) -> ReturnView: """Remove an email address from the user's account.""" - useremail: Union[None, UserEmail, UserEmailClaim] + accountemail: AccountEmail | AccountEmailClaim | None try: - useremail = UserEmail.get_for(user=current_auth.user, email_hash=email_hash) - if useremail is None: - useremail = UserEmailClaim.get_for( - user=current_auth.user, email_hash=email_hash + accountemail = AccountEmail.get_for( + account=current_auth.user, email_hash=email_hash + ) + if accountemail is None: + accountemail = AccountEmailClaim.get_for( + account=current_auth.user, email_hash=email_hash ) - if useremail is None: + if accountemail is None: abort(404) except ValueError: # Possible when email_hash is invalid Base58 abort(404) if ( - isinstance(useremail, UserEmail) + isinstance(accountemail, AccountEmail) and current_auth.user.verified_contact_count == 1 ): flash( @@ -597,14 +645,14 @@ def remove_email(self, email_hash: str) -> ReturnView: ) return render_redirect(url_for('account')) result = render_delete_sqla( - useremail, + accountemail, db, title=_("Confirm removal"), message=_("Remove email address {email} from your account?").format( - email=useremail.email + email=accountemail.email ), success=_("You have removed your email address {email}").format( - email=useremail.email + email=accountemail.email ), next=url_for('account'), delete_text=_("Remove"), @@ -616,9 +664,9 @@ def remove_email(self, email_hash: str) -> ReturnView: @route( 'email//verify', methods=['GET', 'POST'], - endpoint='verify_email', + endpoint='verify_email_legacy', ) - def verify_email(self, email_hash: str) -> ReturnView: + def verify_email_legacy(self, email_hash: str) -> ReturnView: """ Allow user to resend an email verification link if original is lost. @@ -626,10 +674,10 @@ def verify_email(self, email_hash: str) -> ReturnView: addresses pending verification. """ try: - useremail = UserEmail.get(email_hash=email_hash) + accountemail = AccountEmail.get(email_hash=email_hash) except ValueError: # Possible when email_hash is invalid Base58 abort(404) - if useremail is not None and useremail.user == current_auth.user: + if accountemail is not None and accountemail.account == current_auth.user: # If an email address is already verified (this should not happen unless the # user followed a stale link), tell them it's done -- but only if the email # address belongs to this user, to prevent this endpoint from being used as @@ -639,8 +687,8 @@ def verify_email(self, email_hash: str) -> ReturnView: # Get the existing email claim that we're resending a verification link for try: - emailclaim = UserEmailClaim.get_for( - user=current_auth.user, email_hash=email_hash + emailclaim = AccountEmailClaim.get_for( + account=current_auth.user, email_hash=email_hash ) except ValueError: # Possible when email_hash is invalid Base58 abort(404) @@ -658,7 +706,7 @@ def verify_email(self, email_hash: str) -> ReturnView: message=_("We will resend the verification email to {email}").format( email=emailclaim.email ), - formid="email_verify", + formid='email_verify', submit=_("Send"), template='account_formlayout.html.jinja2', ) @@ -701,25 +749,28 @@ def verify_phone(self) -> ReturnView: if form.validate_on_submit(): OtpSession.delete() if TYPE_CHECKING: - assert otp_session.phone is not None # nosec - if UserPhone.get(otp_session.phone) is None: - # If there are no existing phone numbers, this will be a primary + assert otp_session.phone is not None # nosec B101 + existing = AccountPhone.get(otp_session.phone) + if existing is None: + # This phone number is available to claim. If there are no other + # phone numbers in this account, this will be a primary primary = not current_auth.user.phones - userphone = UserPhone(user=current_auth.user, phone=otp_session.phone) - userphone.primary = primary - db.session.add(userphone) - userphone.phone_number.mark_active() + accountphone = AccountPhone( + account=current_auth.user, phone=otp_session.phone + ) + accountphone.primary = primary + db.session.add(accountphone) + accountphone.phone_number.mark_active(sms=True) db.session.commit() flash(_("Your phone number has been verified"), 'success') user_data_changed.send(current_auth.user, changes=['phone']) return render_redirect( get_next_url(session=True, default=url_for('account')) ) - flash( - _("This phone number has already been claimed by another user"), - 'danger', - ) - return render_redirect(url_for('add_phone')) + # Already linked to another user, but we have verified the ownership, so + # proceed to merge account flow here + session['merge_buid'] = existing.user.buid + return render_redirect(url_for('account_merge'), 303) return render_form( form=form, title=_("Verify phone number"), @@ -735,19 +786,21 @@ def verify_phone(self) -> ReturnView: @requires_sudo def remove_phone(self, phone_hash: str) -> ReturnView: """Remove a phone number from the user's account.""" - userphone = UserPhone.get_for(user=current_auth.user, phone_hash=phone_hash) - if userphone is None: + accountphone = AccountPhone.get_for( + account=current_auth.user, phone_hash=phone_hash + ) + if accountphone is None: abort(404) result = render_delete_sqla( - userphone, + accountphone, db, title=_("Confirm removal"), message=_("Remove phone number {phone} from your account?").format( - phone=userphone.formatted + phone=accountphone.formatted ), success=_("You have removed your number {phone}").format( - phone=userphone.formatted + phone=accountphone.formatted ), next=url_for('account'), delete_text=_("Remove"), @@ -766,20 +819,22 @@ def remove_phone(self, phone_hash: str) -> ReturnView: @requires_sudo def remove_extid(self, service: str, userid: str) -> ReturnView: """Remove a connected external account.""" - extid = UserExternalId.query.filter_by( + extid = AccountExternalId.query.filter_by( user=current_auth.user, service=service, userid=userid ).one_or_404() + if extid.service in login_registry: + service_title = login_registry[extid.service].title + else: + service_title = capwords(extid.service) return render_delete_sqla( extid, db, title=_("Confirm removal"), message=_( "Remove {service} account ‘{username}’ from your account?" - ).format( - service=login_registry[extid.service].title, username=extid.username - ), + ).format(service=service_title, username=extid.username), success=_("You have removed the {service} account ‘{username}’").format( - service=login_registry[extid.service].title, username=extid.username + service=service_title, username=extid.username ), next=url_for('account'), delete_text=_("Remove"), @@ -807,6 +862,7 @@ def delete(self): ) return render_form( form=form, + formid='account-delete', title=_("You are about to delete your account permanently"), submit=("Delete account"), ajax=False, @@ -819,6 +875,7 @@ def delete(self): # --- Compatibility routes ------------------------------------------------------------- + # Retained for future hasjob integration # @hasjobapp.route('/account/sudo', endpoint='account_sudo') def otherapp_account_sudo() -> ReturnResponse: diff --git a/funnel/views/account_delete.py b/funnel/views/account_delete.py index f4eaa7cba..a9130b473 100644 --- a/funnel/views/account_delete.py +++ b/funnel/views/account_delete.py @@ -1,21 +1,23 @@ """Helper functions for account delete validation.""" +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, List, Optional +from typing import TypeVar from baseframe import __ -from ..models import User -from ..typing import ReturnDecorator, WrappedFunc +from ..models import Account # --- Delete validator registry -------------------------------------------------------- +ValidatorFunc = TypeVar('ValidatorFunc', bound=Callable[[Account], bool]) + @dataclass class DeleteValidator: """Delete validator metadata.""" - validate: Callable[[User], bool] + validate: Callable[[Account], bool] name: str title: str message: str @@ -23,15 +25,15 @@ class DeleteValidator: #: A list of validators that confirm there is no objection to deleting a user #: account (returning True to allow deletion to proceed). -account_delete_validators: List[DeleteValidator] = [] +account_delete_validators: list[DeleteValidator] = [] def delete_validator( - title: str, message: str, name: Optional[str] = None -) -> ReturnDecorator: + title: str, message: str, name: str | None = None +) -> Callable[[ValidatorFunc], ValidatorFunc]: """Register an account delete validator.""" - def decorator(func: WrappedFunc) -> WrappedFunc: + def decorator(func: ValidatorFunc) -> ValidatorFunc: """Create a DeleteValidator.""" account_delete_validators.append( DeleteValidator(func, name or func.__name__, title, message) @@ -48,9 +50,9 @@ def decorator(func: WrappedFunc) -> WrappedFunc: title=__("This account is protected"), message=__("Protected accounts cannot be deleted"), ) -def profile_is_protected(user: User) -> bool: +def profile_is_protected(user: Account) -> bool: """Block deletion if the user has a protected account.""" - if user.profile is not None and user.profile.is_protected: + if user.is_protected: return False return True @@ -62,7 +64,7 @@ def profile_is_protected(user: User) -> bool: " account can be deleted" ), ) -def single_owner_organization(user: User) -> bool: +def single_owner_organization(user: Account) -> bool: """Fail if user is the sole owner of one or more organizations.""" # TODO: Optimize org.owner_users lookup for large organizations return all(tuple(org.owner_users) != (user,) for org in user.organizations_as_owner) @@ -75,13 +77,9 @@ def single_owner_organization(user: User) -> bool: " transferred to a new host before the account can be deleted" ), ) -def profile_has_projects(user: User) -> bool: +def profile_has_projects(user: Account) -> bool: """Fail if user has projects in their account.""" - if user.profile is not None: - # TODO: Break down `is_safe_to_delete()` into individual components - # and apply to org delete as well - return user.profile.is_safe_to_delete() - return True + return user.is_safe_to_delete() @delete_validator( @@ -91,7 +89,7 @@ def profile_has_projects(user: User) -> bool: " can be deleted" ), ) -def user_owns_apps(user: User) -> bool: +def user_owns_apps(user: Account) -> bool: """Fail if user is the owner of client apps.""" if user.clients: return False @@ -101,8 +99,8 @@ def user_owns_apps(user: User) -> bool: # --- Delete validator view helper ----------------------------------------------------- -@User.views() -def validate_account_delete(obj: User) -> Optional[DeleteValidator]: +@Account.views() +def validate_account_delete(obj: Account) -> DeleteValidator | None: """Validate if user account is safe to delete, returning an optional objection.""" for validator in account_delete_validators: proceed = validator.validate(obj) diff --git a/funnel/views/account_reset.py b/funnel/views/account_reset.py index 9da24c733..c945bd73c 100644 --- a/funnel/views/account_reset.py +++ b/funnel/views/account_reset.py @@ -5,18 +5,10 @@ from datetime import timedelta from typing import TYPE_CHECKING -from flask import ( - Markup, - current_app, - escape, - flash, - redirect, - request, - session, - url_for, -) -from flask_babel import ngettext import itsdangerous +from flask import current_app, flash, redirect, request, session, url_for +from flask_babel import ngettext +from markupsafe import Markup, escape from baseframe import _ from baseframe.forms import render_form, render_message @@ -25,7 +17,7 @@ from .. import app from ..forms import OtpForm, PasswordCreateForm, PasswordResetRequestForm -from ..models import AccountPasswordNotification, User, db +from ..models import Account, AccountPasswordNotification, db from ..registry import login_registry from ..serializers import token_serializer from ..typing import ReturnView @@ -63,7 +55,7 @@ def reset() -> ReturnView: user = form.user anchor = form.anchor if TYPE_CHECKING: - assert isinstance(user, User) # nosec + assert isinstance(user, Account) # nosec if not anchor: # User has no phone or email. Maybe they logged in via Twitter # and set a local username and password, but no email. Could happen @@ -227,7 +219,7 @@ def reset_with_token_do() -> ReturnView: return render_redirect(url_for('reset')) # 3. We have a token and it's not expired. Is there a user? - user = User.get(buid=token['buid']) + user = Account.get(buid=token['buid']) if user is None: # If the user has disappeared, it's likely because this is a dev instance and # the local database has been dropped -- or a future scenario in which db entry @@ -262,10 +254,10 @@ def reset_with_token_do() -> ReturnView: user.password = form.password.data session.pop('reset_token', None) # Invalidate all of the user's active sessions - user_sessions = user.active_user_sessions.all() - session_count = len(user_sessions) - for user_session in user_sessions: - user_session.revoke() + login_sessions = user.active_login_sessions.all() + session_count = len(login_sessions) + for login_session in login_sessions: + login_session.revoke() db.session.commit() dispatch_notification(AccountPasswordNotification(document=user)) return render_message( diff --git a/funnel/views/api/__init__.py b/funnel/views/api/__init__.py index f238ac371..ebadd6087 100644 --- a/funnel/views/api/__init__.py +++ b/funnel/views/api/__init__.py @@ -11,4 +11,5 @@ resource, shortlink, sms_events, + support, ) diff --git a/funnel/views/api/email_events.py b/funnel/views/api/email_events.py index 1496e6d97..d07a2c527 100644 --- a/funnel/views/api/email_events.py +++ b/funnel/views/api/email_events.py @@ -2,12 +2,11 @@ from __future__ import annotations +from collections.abc import Sequence from email.utils import parseaddr -from typing import List - -from flask import current_app, request import requests +from flask import current_app, request from baseframe import statsd @@ -187,7 +186,7 @@ def click(self, ses_event: SesEvent) -> None: processor: SesProcessor = SesProcessor() # SNS Headers that should be present in all messages -sns_headers: List[str] = [ +sns_headers: list[str] = [ 'x-amz-sns-message-type', 'x-amz-sns-message-id', 'x-amz-sns-topic-arn', @@ -229,7 +228,10 @@ def process_ses_event() -> ReturnView: # Validate the message try: - validator.topics = app.config['SES_NOTIFICATION_TOPICS'] + config_topics: Sequence[str] | None = app.config.get('SES_NOTIFICATION_TOPICS') + if not config_topics: + app.logger.error("Config key SES_NOTIFICATION_TOPICS is not set") + validator.topics = config_topics or [] validator.check(message) except SnsValidatorError: current_app.logger.warning("SNS/SES event failed validation: %r", message) diff --git a/funnel/views/api/geoname.py b/funnel/views/api/geoname.py index 125e9c359..00c90c463 100644 --- a/funnel/views/api/geoname.py +++ b/funnel/views/api/geoname.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import List, Optional - from coaster.utils import getbool from coaster.views import requestargs @@ -37,7 +35,7 @@ def geo_get_by_name( @app.route('/api/1/geo/get_by_names') @requestargs('name[]', ('related', getbool), ('alternate_titles', getbool)) def geo_get_by_names( - name: List[str], related: bool = False, alternate_titles: bool = False + name: list[str], related: bool = False, alternate_titles: bool = False ) -> ReturnRenderWith: """Get geoname records matching given URL stub names or geonameids.""" geonames = [] @@ -59,7 +57,7 @@ def geo_get_by_names( @app.route('/api/1/geo/get_by_title') @requestargs('title[]', 'lang') -def geo_get_by_title(title: List[str], lang: Optional[str] = None) -> ReturnRenderWith: +def geo_get_by_title(title: list[str], lang: str | None = None) -> ReturnRenderWith: """Get locations matching given titles.""" return { 'status': 'ok', @@ -71,9 +69,9 @@ def geo_get_by_title(title: List[str], lang: Optional[str] = None) -> ReturnRend @requestargs('q', 'special[]', 'lang', 'bias[]', ('alternate_titles', getbool)) def geo_parse_location( q: str, - special: Optional[List[str]] = None, - lang: Optional[str] = None, - bias: Optional[List[str]] = None, + special: list[str] | None = None, + lang: str | None = None, + bias: list[str] | None = None, alternate_titles: bool = False, ) -> ReturnRenderWith: """Parse locations from a string of locations.""" @@ -87,7 +85,7 @@ def geo_parse_location( @app.route('/api/1/geo/autocomplete') @requestargs('q', 'lang', ('limit', int)) def geo_autocomplete( - q: str, lang: Optional[str] = None, limit: int = 100 + q: str, lang: str | None = None, limit: int = 100 ) -> ReturnRenderWith: """Autocomplete a geoname record.""" return { diff --git a/funnel/views/api/markdown.py b/funnel/views/api/markdown.py index aa767e43e..6b5a11f60 100644 --- a/funnel/views/api/markdown.py +++ b/funnel/views/api/markdown.py @@ -1,8 +1,6 @@ """Markdown preview view.""" -from typing import Optional - from flask import request from baseframe import _ @@ -16,7 +14,7 @@ @app.route('/api/1/preview/markdown', methods=['POST']) def markdown_preview() -> ReturnView: """Render Markdown in the backend, with custom options based on use case.""" - profile: Optional[str] = request.form.get('profile') + profile: str | None = request.form.get('profile') if profile is None or profile not in MarkdownConfig.registry: return { 'status': 'error', diff --git a/funnel/views/api/oauth.py b/funnel/views/api/oauth.py index 3761a1ab8..945ae619d 100644 --- a/funnel/views/api/oauth.py +++ b/funnel/views/api/oauth.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Iterable, List, Optional, cast +from collections.abc import Iterable +from typing import Optional, cast from flask import get_flashed_messages, jsonify, redirect, render_template, request @@ -13,23 +14,19 @@ from ... import app from ...models import ( + Account, AuthClient, AuthClientCredential, AuthCode, AuthToken, - User, - UserSession, + LoginSession, db, getuser, ) from ...registry import resource_registry from ...typing import ReturnView -from ...utils import abort_null, make_redirect_url -from ..login_session import ( - reload_for_cookies, - requires_client_login, - requires_login_no_message, -) +from ...utils import make_redirect_url +from ..login_session import reload_for_cookies, requires_client_login, requires_login from .resource import get_userinfo @@ -37,7 +34,7 @@ class ScopeError(Exception): """Requested scope is invalid or beyond access level.""" -def verifyscope(scope: Iterable, auth_client: AuthClient) -> List[str]: +def verifyscope(scope: Iterable, auth_client: AuthClient) -> list[str]: """Verify if requested scope is valid for this client.""" internal_resources = [] # Names of internal resources @@ -86,8 +83,8 @@ def oauth_make_auth_code( Caller must commit the database session for this to work. """ authcode = AuthCode( - user=current_auth.user, - user_session=current_auth.session, + account=current_auth.user, + login_session=current_auth.session, auth_client=auth_client, scope=scope, redirect_uri=redirect_uri[:1024], @@ -112,8 +109,8 @@ def oauth_auth_success( auth_client: AuthClient, redirect_uri: str, state: str, - code: Optional[str], - token: Optional[AuthToken] = None, + code: str | None, + token: AuthToken | None = None, ) -> ReturnView: """Commit session and redirect to OAuth redirect URI.""" clear_flashed_messages() @@ -152,8 +149,8 @@ def oauth_auth_error( redirect_uri: str, state: str, error: str, - error_description: Optional[str] = None, - error_uri: Optional[str] = None, + error_description: str | None = None, + error_uri: str | None = None, ) -> ReturnView: """Return to auth client indicating that auth request resulted in an error.""" params = {'error': error} @@ -175,7 +172,7 @@ def oauth_auth_error( @app.route('/api/1/auth', methods=['GET', 'POST']) @reload_for_cookies -@requires_login_no_message +@requires_login('') def oauth_authorize() -> ReturnView: """Provide authorization endpoint for OAuth2 server.""" form = forms.Form() @@ -334,7 +331,7 @@ def oauth_authorize() -> ReturnView: def oauth_token_error( - error: str, error_description: Optional[str] = None, error_uri: Optional[str] = None + error: str, error_description: str | None = None, error_uri: str | None = None ) -> ReturnView: """Return an error status when validating an OAuth2 token request.""" params = {'error': error} @@ -350,14 +347,14 @@ def oauth_token_error( def oauth_make_token( - user: Optional[User], + user: Account | None, auth_client: AuthClient, scope: Iterable, - user_session: Optional[UserSession] = None, + login_session: LoginSession | None = None, ) -> AuthToken: """Make an OAuth2 token for the given user, client, scope and optional session.""" # Look for an existing token - token = auth_client.authtoken_for(user, user_session) + token = auth_client.authtoken_for(user, login_session) # If token exists, add to the existing scope if token is not None: @@ -368,15 +365,15 @@ def oauth_make_token( if user is None: raise ValueError("User not provided") token = AuthToken( # nosec - user=user, auth_client=auth_client, scope=scope, token_type='bearer' + account=user, auth_client=auth_client, scope=scope, token_type='bearer' ) token = cast( AuthToken, - failsafe_add(db.session, token, user=user, auth_client=auth_client), + failsafe_add(db.session, token, account=user, auth_client=auth_client), ) - elif user_session is not None: + elif login_session is not None: token = AuthToken( # nosec - user_session=user_session, + login_session=login_session, auth_client=auth_client, scope=scope, token_type='bearer', @@ -386,12 +383,12 @@ def oauth_make_token( failsafe_add( db.session, token, - user_session=user_session, + login_session=login_session, auth_client=auth_client, ), ) else: - raise ValueError("user_session not provided") + raise ValueError("login_session not provided") return token @@ -404,7 +401,7 @@ def oauth_token_success(token: AuthToken, **params) -> ReturnView: if token.validity: params['expires_in'] = token.validity # No refresh tokens for client_credentials tokens - if token.user is not None: + if token.effective_user is not None: params['refresh_token'] = token.refresh_token response = jsonify(**params) response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' @@ -418,20 +415,20 @@ def oauth_token_success(token: AuthToken, **params) -> ReturnView: def oauth_token() -> ReturnView: """Provide token endpoint for OAuth2 server.""" # Always required parameters - grant_type = cast(Optional[str], abort_null(request.form.get('grant_type'))) + grant_type = cast(Optional[str], request.form.get('grant_type')) auth_client = current_auth.auth_client # Provided by @requires_client_login - scope = abort_null(request.form.get('scope', '')).split(' ') + scope = request.form.get('scope', '').split(' ') # if grant_type == 'authorization_code' (POST) - code = cast(Optional[str], abort_null(request.form.get('code'))) - redirect_uri = cast(Optional[str], abort_null(request.form.get('redirect_uri'))) + code = cast(Optional[str], request.form.get('code')) + redirect_uri = cast(Optional[str], request.form.get('redirect_uri')) # if grant_type == 'password' (POST) - username = cast(Optional[str], abort_null(request.form.get('username'))) - password = cast(Optional[str], abort_null(request.form.get('password'))) + username = cast(Optional[str], request.form.get('username')) + password = cast(Optional[str], request.form.get('password')) # if grant_type == 'client_credentials' buid = cast( Optional[str], # XXX: Deprecated userid parameter - abort_null(request.form.get('buid') or request.form.get('userid')), + request.form.get('buid') or request.form.get('userid'), ) # Validations 1: Required parameters @@ -453,7 +450,7 @@ def oauth_token() -> ReturnView: if buid: if auth_client.trusted: - user = User.get(buid=buid) + user = Account.get(buid=buid) if user is not None: # This client is trusted and can receive a user access token. # However, don't grant it the scope it wants as the user's @@ -465,7 +462,7 @@ def oauth_token() -> ReturnView: return oauth_token_success( token, userinfo=get_userinfo( - user=token.user, + user=token.effective_user, auth_client=auth_client, scope=token.effective_scope, ), @@ -497,16 +494,16 @@ def oauth_token() -> ReturnView: return oauth_token_error('invalid_client', _("redirect_uri does not match")) token = oauth_make_token( - user=authcode.user, auth_client=auth_client, scope=scope + user=authcode.account, auth_client=auth_client, scope=scope ) db.session.delete(authcode) return oauth_token_success( token, userinfo=get_userinfo( - user=authcode.user, + user=authcode.account, auth_client=auth_client, scope=token.effective_scope, - user_session=authcode.user_session, + login_session=authcode.login_session, ), ) diff --git a/funnel/views/api/resource.py b/funnel/views/api/resource.py index e52d3cd03..22226b961 100644 --- a/funnel/views/api/resource.py +++ b/funnel/views/api/resource.py @@ -2,9 +2,11 @@ from __future__ import annotations -from typing import Any, Container, Dict, List, Optional, cast +from collections.abc import Container +from typing import Any, Literal, cast from flask import abort, jsonify, render_template, request +from werkzeug.datastructures import MultiDict from baseframe import __ from coaster.auth import current_auth @@ -13,15 +15,14 @@ from ... import app from ...models import ( + Account, AuthClient, AuthClientCredential, - AuthClientTeamPermissions, - AuthClientUserPermissions, + AuthClientPermissions, AuthToken, + LoginSession, Organization, - Profile, User, - UserSession, db, getuser, ) @@ -35,15 +36,15 @@ requires_user_or_client_login, ) -ReturnResource = Dict[str, Any] +ReturnResource = dict[str, Any] def get_userinfo( - user: User, + user: Account, auth_client: AuthClient, scope: Container[str] = (), - user_session: Optional[UserSession] = None, - get_permissions=True, + login_session: LoginSession | None = None, + get_permissions: bool = True, ) -> ReturnResource: """Return userinfo for a given user, auth client and scope.""" if '*' in scope or 'id' in scope or 'id/*' in scope: @@ -54,15 +55,15 @@ def get_userinfo( 'username': user.username, 'fullname': user.fullname, 'timezone': user.timezone, - 'avatar': user.avatar, + 'avatar': user.logo_url, 'oldids': [o.buid for o in user.oldids], 'olduuids': [o.uuid for o in user.oldids], } else: userinfo = {} - if user_session is not None: - userinfo['sessionid'] = user_session.buid + if login_session is not None: + userinfo['sessionid'] = login_session.buid if '*' in scope or 'email' in scope or 'email/*' in scope: userinfo['email'] = str(user.email) @@ -75,7 +76,7 @@ def get_userinfo( 'userid': org.buid, 'buid': org.buid, 'uuid': org.uuid, - 'name': org.name, + 'name': org.urlname, 'title': org.title, } for org in user.organizations_as_owner @@ -85,7 +86,7 @@ def get_userinfo( 'userid': org.buid, 'buid': org.buid, 'uuid': org.uuid, - 'name': org.name, + 'name': org.urlname, 'title': org.title, } for org in user.organizations_as_admin @@ -93,19 +94,9 @@ def get_userinfo( } if get_permissions: - if auth_client.user: - uperms = AuthClientUserPermissions.get(auth_client=auth_client, user=user) - if uperms is not None: - userinfo['permissions'] = uperms.access_permissions.split(' ') - else: - permsset = set() - if user.teams: - all_perms = AuthClientTeamPermissions.all_for( - auth_client=auth_client, user=user - ).all() - for tperms in all_perms: - permsset.update(tperms.access_permissions.split(' ')) - userinfo['permissions'] = sorted(permsset) + uperms = AuthClientPermissions.get(auth_client=auth_client, account=user) + if uperms is not None: + userinfo['permissions'] = uperms.access_permissions.split(' ') return userinfo @@ -126,11 +117,15 @@ def resource_error(error, description=None, uri=None) -> Response: return response -def api_result(status, _jsonp=False, **params) -> Response: +def api_result( + status: Literal['ok'] | Literal['error'] | Literal[200] | Literal[201], + _jsonp: bool = False, + **params: Any, +) -> Response: """Return an API result.""" status_code = 200 if status in (200, 201): - status_code = status + status_code = status # type: ignore[assignment] status = 'ok' elif status == 'error': status_code = 422 @@ -174,7 +169,7 @@ def user_get_by_userid() -> ReturnView: buid = abort_null(request.values.get('userid')) if not buid: return api_result('error', error='no_userid_provided') - user = User.get(buid=buid, defercols=True) + user = Account.get(buid=buid, defercols=True) if user is not None: return api_result( 'ok', @@ -199,7 +194,7 @@ def user_get_by_userid() -> ReturnView: userid=org.buid, buid=org.buid, uuid=org.uuid, - name=org.name, + name=org.urlname, title=org.title, label=org.pickername, ) @@ -209,7 +204,7 @@ def user_get_by_userid() -> ReturnView: @app.route('/api/1/user/get_by_userids', methods=['GET', 'POST']) @requires_client_id_or_user_or_client_login @requestargs(('userid[]', abort_null)) -def user_get_by_userids(userid: List[str]) -> ReturnView: +def user_get_by_userids(userid: list[str]) -> ReturnView: """ Return users and organizations with the given userids (Lastuser internal userid). @@ -219,7 +214,7 @@ def user_get_by_userids(userid: List[str]) -> ReturnView: if not userid: return api_result('error', error='no_userid_provided', _jsonp=True) # `userid` parameter is a list, not a scalar, since requestargs has `userid[]` - users = User.all(buids=userid) + users = Account.all(buids=userid) orgs = Organization.all(buids=userid) return api_result( 'ok', @@ -282,7 +277,7 @@ def user_get(name: str) -> ReturnView: @app.route('/api/1/user/getusers', methods=['GET', 'POST']) @requires_user_or_client_login @requestargs(('name[]', abort_null)) -def user_getall(name: List[str]) -> ReturnView: +def user_getall(name: list[str]) -> ReturnView: """Return users with the given username or email address.""" names = name buids = set() # Dupe checker @@ -323,8 +318,8 @@ def user_autocomplete(q: str = '') -> ReturnView: """ if not q: return api_result('error', error='no_query_provided') - # Limit length of query to User.fullname limit - q = q[: User.__title_length__] + # Limit length of query to Account.title limit + q = q[: Account.__title_length__] # Setup rate limiter to not count progressive typing or backspacing towards # attempts. That is, sending 'abc' after 'ab' will not count towards limits, but @@ -333,13 +328,13 @@ def user_autocomplete(q: str = '') -> ReturnView: # imposes a limit of 20 name lookups per half hour. validate_rate_limit( - # As this endpoint accepts client_id+user_session in lieu of login cookie, - # we may not have an authenticated user. Use the user_session's user in that + # As this endpoint accepts client_id+login_session in lieu of login cookie, + # we may not have an authenticated user. Use the login_session's account in that # case 'api_user_autocomplete', current_auth.actor.uuid_b58 if current_auth.actor - else current_auth.session.user.uuid_b58, + else current_auth.session.account.uuid_b58, # Limit 20 attempts 20, # Per half hour (60s * 30m = 1800s) @@ -372,8 +367,8 @@ def profile_autocomplete(q: str = '') -> ReturnView: if not q: return api_result('error', error='no_query_provided') - # Limit length of query to User.fullname and Organization.title length limit - q = q[: max(User.__title_length__, Organization.__title_length__)] + # Limit length of query to Account.title + q = q[: Account.__title_length__] # Setup rate limiter to not count progressive typing or backspacing towards # attempts. That is, sending 'abc' after 'ab' will not count towards limits, but @@ -382,13 +377,13 @@ def profile_autocomplete(q: str = '') -> ReturnView: # imposes a limit of 20 name lookups per half hour. validate_rate_limit( - # As this endpoint accepts client_id+user_session in lieu of login cookie, - # we may not have an authenticated user. Use the user_session's user in that + # As this endpoint accepts client_id+login_session in lieu of login cookie, + # we may not have an authenticated user. Use the login_session's account in that # case 'api_profile_autocomplete', current_auth.actor.uuid_b58 if current_auth.actor - else current_auth.session.user.uuid_b58, + else current_auth.session.account.uuid_b58, # Limit 20 attempts 20, # Per half hour (60s * 30m = 1800s) @@ -398,7 +393,7 @@ def profile_autocomplete(q: str = '') -> ReturnView: token=q, validator=progressive_rate_limit_validator, ) - profiles = Profile.autocomplete(q) + profiles = Account.autocomplete(q) profile_names = [p.name for p in profiles] # TODO: Update front-end, remove this profile_list = [ { @@ -465,62 +460,77 @@ def login_beacon_json(client_id: str) -> ReturnView: @app.route('/api/1/id') @resource_registry.resource('id', __("Read your name and basic account data")) -def resource_id(authtoken: AuthToken, args: dict, files=None) -> ReturnResource: +def resource_id( + authtoken: AuthToken, args: MultiDict, files: MultiDict | None = None +) -> ReturnResource: """Return user's basic identity.""" if 'all' in args and getbool(args['all']): return get_userinfo( - authtoken.user, + authtoken.effective_user, authtoken.auth_client, scope=authtoken.effective_scope, get_permissions=True, ) return get_userinfo( - authtoken.user, authtoken.auth_client, scope=['id'], get_permissions=False + authtoken.effective_user, + authtoken.auth_client, + scope=['id'], + get_permissions=False, ) @app.route('/api/1/session/verify', methods=['POST']) @resource_registry.resource('session/verify', __("Verify user session"), scope='id') -def session_verify(authtoken: AuthToken, args: dict, files=None) -> ReturnResource: +def session_verify( + authtoken: AuthToken, args: MultiDict, files: MultiDict | None = None +) -> ReturnResource: """Verify a UserSession.""" sessionid = abort_null(args['sessionid']) - user_session = UserSession.authenticate(buid=sessionid, silent=True) - if user_session is not None and user_session.user == authtoken.user: - user_session.views.mark_accessed(auth_client=authtoken.auth_client) + login_session = LoginSession.authenticate(buid=sessionid, silent=True) + if login_session is not None and login_session.account == authtoken.effective_user: + login_session.views.mark_accessed(auth_client=authtoken.auth_client) db.session.commit() return { 'active': True, - 'sessionid': user_session.buid, - 'userid': user_session.user.buid, - 'buid': user_session.user.buid, - 'user_uuid': user_session.user.uuid, - 'sudo': user_session.has_sudo, + 'sessionid': login_session.buid, + 'userid': login_session.account.buid, + 'buid': login_session.account.buid, + 'user_uuid': login_session.account.uuid, + 'sudo': login_session.has_sudo, } return {'active': False} @app.route('/api/1/email') @resource_registry.resource('email', __("Read your email address")) -def resource_email(authtoken: AuthToken, args: dict, files=None) -> ReturnResource: +def resource_email( + authtoken: AuthToken, args: MultiDict, files: MultiDict | None = None +) -> ReturnResource: """Return user's email addresses.""" if 'all' in args and getbool(args['all']): return { - 'email': str(authtoken.user.email), - 'all': [str(email) for email in authtoken.user.emails if not email.private], + 'email': str(authtoken.effective_user.email), + 'all': [ + str(email) + for email in authtoken.effective_user.emails + if not email.private + ], } - return {'email': str(authtoken.user.email)} + return {'email': str(authtoken.effective_user.email)} @app.route('/api/1/phone') @resource_registry.resource('phone', __("Read your phone number")) -def resource_phone(authtoken: AuthToken, args: dict, files=None) -> ReturnResource: +def resource_phone( + authtoken: AuthToken, args: MultiDict, files: MultiDict | None = None +) -> ReturnResource: """Return user's phone numbers.""" if 'all' in args and getbool(args['all']): return { - 'phone': str(authtoken.user.phone), - 'all': [str(phone) for phone in authtoken.user.phones], + 'phone': str(authtoken.effective_user.phone), + 'all': [str(phone) for phone in authtoken.effective_user.phones], } - return {'phone': str(authtoken.user.phone)} + return {'phone': str(authtoken.effective_user.phone)} @app.route('/api/1/user/externalids') @@ -530,12 +540,12 @@ def resource_phone(authtoken: AuthToken, args: dict, files=None) -> ReturnResour trusted=True, ) def resource_login_providers( - authtoken: AuthToken, args: dict, files=None + authtoken: AuthToken, args: MultiDict, files: MultiDict | None = None ) -> ReturnResource: """Return user's login providers' data.""" - service: Optional[str] = abort_null(args.get('service')) + service: str | None = abort_null(args.get('service')) response = {} - for extid in authtoken.user.externalids: + for extid in authtoken.effective_user.externalids: if service is None or extid.service == service: response[cast(str, extid.service)] = { 'userid': str(extid.userid), @@ -552,11 +562,11 @@ def resource_login_providers( 'organizations', __("Read the organizations you are a member of") ) def resource_organizations( - authtoken: AuthToken, args: dict, files=None + authtoken: AuthToken, args: MultiDict, files: MultiDict | None = None ) -> ReturnResource: """Return user's organizations and teams that they are a member of.""" return get_userinfo( - authtoken.user, + authtoken.effective_user, authtoken.auth_client, scope=['organizations'], get_permissions=False, @@ -565,8 +575,13 @@ def resource_organizations( @app.route('/api/1/teams') @resource_registry.resource('teams', __("Read the list of teams in your organizations")) -def resource_teams(authtoken: AuthToken, args: dict, files=None) -> ReturnResource: +def resource_teams( + authtoken: AuthToken, args: MultiDict, files: MultiDict | None = None +) -> ReturnResource: """Return user's organizations' teams.""" return get_userinfo( - authtoken.user, authtoken.auth_client, scope=['teams'], get_permissions=False + authtoken.effective_user, + authtoken.auth_client, + scope=['teams'], + get_permissions=False, ) diff --git a/funnel/views/api/shortlink.py b/funnel/views/api/shortlink.py index 7dfef56d0..68c657d41 100644 --- a/funnel/views/api/shortlink.py +++ b/funnel/views/api/shortlink.py @@ -1,6 +1,5 @@ """API view for creating a shortlink to any content on the website.""" -from typing import Dict, Optional, Tuple, Union from furl import furl @@ -11,16 +10,15 @@ from ... import app, shortlinkapp from ...models import Shortlink, db -from ...utils import abort_null from ..helpers import app_url_for, validate_is_app_url # Add future hasjobapp route here @app.route('/api/1/shortlink/create', methods=['POST']) -@requestform(('url', abort_null), ('shorter', getbool), ('name', abort_null)) +@requestform('url', ('shorter', getbool), 'name') def create_shortlink( - url: Union[str, furl], shorter: bool = True, name: Optional[str] = None -) -> Tuple[Dict[str, str], int]: + url: str | furl, shorter: bool = True, name: str | None = None +) -> tuple[dict[str, str], int]: """Create a shortlink that's valid for URLs in the app.""" # Validate URL to be local before allowing a shortlink to it. if url: diff --git a/funnel/views/api/sms_events.py b/funnel/views/api/sms_events.py index b1cb3f44f..22053843d 100644 --- a/funnel/views/api/sms_events.py +++ b/funnel/views/api/sms_events.py @@ -3,7 +3,6 @@ from __future__ import annotations from flask import current_app, request - from twilio.request_validator import RequestValidator from baseframe import statsd @@ -20,7 +19,6 @@ ) from ...transports.sms import validate_exotel_token from ...typing import ReturnView -from ...utils import abort_null @app.route('/api/1/sms/twilio_event', methods=['POST']) @@ -85,6 +83,7 @@ def process_twilio_event() -> ReturnView: elif request.form['MessageStatus'] == 'delivered': if phone_number: phone_number.msg_sms_delivered_at = sa.func.utcnow() + phone_number.mark_has_sms(True) if sms_message: sms_message.status = SMS_STATUS.DELIVERED else: @@ -116,7 +115,7 @@ def process_exotel_event(secret_token: str) -> ReturnView: # If there are too many rejects, then most likely a hack attempt. statsd.incr('phone_number.event', tags={'engine': 'exotel', 'stage': 'received'}) - exotel_to = abort_null(request.form.get('To', '')) + exotel_to = request.form.get('To', '') if not exotel_to: return {'status': 'eror', 'error': 'invalid_phone'}, 422 # Exotel sends back 0-prefixed phone numbers, not plus-prefixed intl. numbers @@ -173,6 +172,7 @@ def process_exotel_event(secret_token: str) -> ReturnView: elif request.form['Status'] == 'sent': if phone_number: phone_number.msg_sms_delivered_at = sa.func.utcnow() + phone_number.mark_has_sms(True) if sms_message: sms_message.status = SMS_STATUS.DELIVERED else: diff --git a/funnel/views/api/support.py b/funnel/views/api/support.py new file mode 100644 index 000000000..37f909c29 --- /dev/null +++ b/funnel/views/api/support.py @@ -0,0 +1,68 @@ +"""Support API, internal use only.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import wraps +from typing import Any + +from flask import abort, request + +from baseframe import _ +from coaster.views import requestform + +from ... import app +from ...models import PhoneNumber, parse_phone_number +from ...typing import P, T + + +def requires_support_auth_token(f: Callable[P, T]) -> Callable[P, T]: + """Check for support API token before accepting the request.""" + + @wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + """Wrap a view.""" + api_key = app.config.get('INTERNAL_SUPPORT_API_KEY') + if not api_key: + abort(501, description=_("API key is not configured")) + if request.headers.get('Authorization') != f'Bearer {api_key}': + abort(403) + return f(*args, **kwargs) + + return wrapper + + +@app.route('/api/1/support/callerid', methods=['POST']) +@requires_support_auth_token +@requestform('number') +def support_callerid(number: str) -> tuple[dict[str, Any], int]: + """Retrieve information about a phone number for caller id.""" + parsed_number = parse_phone_number(number) + if not parsed_number: + return { + 'status': 'error', + 'error': 'invalid', + 'error_description': _("Invalid phone number"), + }, 422 + phone_number = PhoneNumber.get(parsed_number) + if not phone_number: + return { + 'status': 'error', + 'error': 'unknown', + 'error_description': _("Unknown phone number"), + }, 422 + + info = { + 'number': phone_number.number, + 'created_at': phone_number.created_at, + 'active_at': phone_number.active_at, + 'is_blocked': phone_number.is_blocked, + } + if phone_number.used_in_account_phone: + user_phone = phone_number.used_in_account_phone[0] + info['account'] = { + 'title': user_phone.account.fullname, + 'name': user_phone.account.username, + } + return {'status': 'ok', 'result': info}, 200 + # TODO: Check in TicketParticipant.phone diff --git a/funnel/views/auth_client.py b/funnel/views/auth_client.py index 7f059fadf..d71ce9b87 100644 --- a/funnel/views/auth_client.py +++ b/funnel/views/auth_client.py @@ -2,9 +2,7 @@ from __future__ import annotations -from typing import List, Tuple - -from flask import Markup, abort, flash, render_template, request, url_for +from flask import flash, render_template, request, url_for from baseframe import _ from baseframe.forms import render_delete_sqla, render_form @@ -23,16 +21,15 @@ AuthClientCredentialForm, AuthClientForm, AuthClientPermissionEditForm, - TeamPermissionAssignForm, UserPermissionAssignForm, ) from ..models import ( + Account, AuthClient, AuthClientCredential, + AuthClientPermissions, AuthClientTeamPermissions, - AuthClientUserPermissions, Team, - User, db, ) from ..typing import ReturnRenderWith, ReturnView @@ -58,7 +55,7 @@ def client_list_all() -> ReturnView: ) -def available_client_owners() -> List[Tuple[str, str]]: +def available_client_owners() -> list[tuple[str, str]]: """Return a list of possible client owners for the current user.""" choices = [] choices.append((current_auth.user.buid, current_auth.user.pickername)) @@ -81,8 +78,7 @@ def new(self) -> ReturnView: if form.validate_on_submit(): auth_client = AuthClient() form.populate_obj(auth_client) - auth_client.user = form.user - auth_client.organization = form.organization + auth_client.account = form.account auth_client.trusted = False db.session.add(auth_client) db.session.commit() @@ -107,17 +103,14 @@ class AuthClientView(UrlForView, ModelView): route_model_map = {'client': 'buid'} obj: AuthClient - def loader(self, client) -> AuthClient: + def loader(self, client: str) -> AuthClient: return AuthClient.query.filter(AuthClient.buid == client).one_or_404() @route('', methods=['GET']) @render_with('auth_client.html.jinja2') @requires_roles({'all'}) def view(self) -> ReturnRenderWith: - if self.obj.user: - permassignments = AuthClientUserPermissions.all_forclient(self.obj).all() - else: - permassignments = AuthClientTeamPermissions.all_forclient(self.obj).all() + permassignments = AuthClientPermissions.all_forclient(self.obj).all() return {'auth_client': self.obj, 'permassignments': permassignments} @route('edit', methods=['GET', 'POST']) @@ -128,15 +121,12 @@ def edit(self) -> ReturnView: form.edit_user = current_auth.user form.client_owner.choices = available_client_owners() if request.method == 'GET': - if self.obj.user: - form.client_owner.data = self.obj.user.buid - else: - form.client_owner.data = self.obj.organization.buid + form.client_owner.data = self.obj.account.buid if form.validate_on_submit(): - if self.obj.user != form.user or self.obj.organization != form.organization: + if self.obj.account != form.account: # Ownership has changed. Remove existing permission assignments - AuthClientUserPermissions.all_forclient(self.obj).delete( + AuthClientPermissions.all_forclient(self.obj).delete( synchronize_session=False ) AuthClientTeamPermissions.all_forclient(self.obj).delete( @@ -150,8 +140,7 @@ def edit(self) -> ReturnView: 'warning', ) form.populate_obj(self.obj) - self.obj.user = form.user - self.obj.organization = form.organization + self.obj.account = form.account db.session.commit() return render_redirect(self.obj.url_for()) @@ -235,69 +224,32 @@ def cred_new(self) -> ReturnView: @requires_login @requires_roles({'owner'}) def permission_user_new(self) -> ReturnView: - if self.obj.user: - form = UserPermissionAssignForm() - elif self.obj.organization: - form = TeamPermissionAssignForm() - form.organization = self.obj.organization - form.team_id.choices = [ - (team.buid, team.title) for team in self.obj.organization.teams - ] - else: - abort(403) # This should never happen. Clients always have an owner. + form = UserPermissionAssignForm() if form.validate_on_submit(): perms = set() - if self.obj.user: - permassign = AuthClientUserPermissions.get( - auth_client=self.obj, user=form.user.data - ) - if permassign is not None: - perms.update(permassign.access_permissions.split()) - else: - permassign = AuthClientUserPermissions( - user=form.user.data, auth_client=self.obj - ) - db.session.add(permassign) + permassign = AuthClientPermissions.get( + auth_client=self.obj, account=form.user.data + ) + if permassign is not None: + perms.update(permassign.access_permissions.split()) else: - permassign = AuthClientTeamPermissions.get( - auth_client=self.obj, team=form.team + permassign = AuthClientPermissions( + account=form.user.data, auth_client=self.obj ) - if permassign is not None: - perms.update(permassign.access_permissions.split()) - else: - permassign = AuthClientTeamPermissions( - team=form.team, auth_client=self.obj - ) - db.session.add(permassign) + db.session.add(permassign) perms.update(form.perms.data.split()) permassign.access_permissions = ' '.join(sorted(perms)) db.session.commit() - if self.obj.user: - flash( - _("Permissions have been assigned to user {pname}").format( - pname=form.user.data.pickername - ), - 'success', - ) - else: - flash( - _("Permissions have been assigned to team ‘{pname}’").format( - pname=permassign.team.pickername - ), - 'success', - ) + flash( + _("Permissions have been assigned to user {pname}").format( + pname=form.user.data.pickername + ), + 'success', + ) return render_redirect(self.obj.url_for()) return render_form( form=form, title=_("Assign permissions"), - message=Markup( - _( - 'Add and edit teams from your organization’s teams' - ' page' - ).format(url=self.obj.organization.url_for('teams')) - ) - if self.obj.organization - else None, formid='perm_assign', submit=_("Assign permissions"), ) @@ -315,7 +267,7 @@ class AuthClientCredentialView(UrlForView, ModelView): route_model_map = {'client': 'auth_client.buid', 'name': 'name'} obj: AuthClientCredential - def loader(self, client, name) -> AuthClientCredential: + def loader(self, client: str, name: str) -> AuthClientCredential: return ( AuthClientCredential.query.join(AuthClient) .filter(AuthClient.buid == client, AuthClientCredential.name == name) @@ -344,20 +296,20 @@ def delete(self) -> ReturnView: # --- Routes: client app permissions ------------------------------------------ -@AuthClientUserPermissions.views('main') +@AuthClientPermissions.views('main') @route('/apps/info//perms/u/') -class AuthClientUserPermissionsView(UrlForView, ModelView): - model = AuthClientUserPermissions - route_model_map = {'client': 'auth_client.buid', 'user': 'user.buid'} - obj: AuthClientUserPermissions +class AuthClientPermissionsView(UrlForView, ModelView): + model = AuthClientPermissions + route_model_map = {'client': 'auth_client.buid', 'account': 'account.buid'} + obj: AuthClientPermissions - def loader(self, client: str, user: str) -> AuthClientUserPermissions: + def loader(self, client: str, user: str) -> AuthClientPermissions: return ( - AuthClientUserPermissions.query.join( - AuthClient, AuthClientUserPermissions.auth_client_id == AuthClient.id + AuthClientPermissions.query.join( + AuthClient, AuthClientPermissions.auth_client ) - .join(User, AuthClientUserPermissions.user_id == User.id) - .filter(AuthClient.buid == client, User.buid == user) + .join(Account, AuthClientPermissions.account) + .filter(AuthClient.buid == client, Account.buid == user) .one_or_404() ) @@ -378,14 +330,14 @@ def edit(self) -> ReturnView: if perms: flash( _("Permissions have been updated for user {pname}").format( - pname=self.obj.user.pickername + pname=self.obj.account.pickername ), 'success', ) else: flash( _("All permissions have been revoked for user {pname}").format( - pname=self.obj.user.pickername + pname=self.obj.account.pickername ), 'success', ) @@ -408,15 +360,17 @@ def delete(self) -> ReturnView: title=_("Confirm delete"), message=_( "Remove all permissions assigned to user {pname} for app ‘{title}’?" - ).format(pname=self.obj.user.pickername, title=self.obj.auth_client.title), + ).format( + pname=self.obj.account.pickername, title=self.obj.auth_client.title + ), success=_("You have revoked permisions for user {pname}").format( - pname=self.obj.user.pickername + pname=self.obj.account.pickername ), next=self.obj.auth_client.url_for(), ) -AuthClientUserPermissionsView.init_app(app) +AuthClientPermissionsView.init_app(app) @AuthClientTeamPermissions.views('main') diff --git a/funnel/views/auth_notify.py b/funnel/views/auth_notify.py index 416ae3006..2162ee0e5 100644 --- a/funnel/views/auth_notify.py +++ b/funnel/views/auth_notify.py @@ -2,7 +2,7 @@ from __future__ import annotations -from ..models import AuthToken +from ..models import Account, AuthToken, LoginSession, Organization, Team from ..signals import ( org_data_changed, session_revoked, @@ -25,14 +25,14 @@ @session_revoked.connect -def notify_session_revoked(session): +def notify_session_revoked(session: LoginSession) -> None: for auth_client in session.auth_clients: if auth_client.trusted and auth_client.notification_uri: send_auth_client_notice.queue( auth_client.notification_uri, data={ - 'userid': session.user.buid, # XXX: Deprecated parameter - 'buid': session.user.buid, + 'userid': session.account.buid, # XXX: Deprecated parameter + 'buid': session.account.buid, 'type': 'user', 'changes': ['logout'], 'sessionid': session.buid, @@ -41,7 +41,7 @@ def notify_session_revoked(session): @user_data_changed.connect -def notify_user_data_changed(user, changes): +def notify_user_data_changed(user: Account, changes) -> None: """Send notifications to trusted auth clients about relevant user data changes.""" if user_changes_to_notify & set(changes): # We have changes that apps need to hear about @@ -92,7 +92,9 @@ def notify_user_data_changed(user, changes): @org_data_changed.connect -def notify_org_data_changed(org, user, changes, team=None): +def notify_org_data_changed( + org: Organization, user: Account, changes, team: Team | None = None +) -> None: """ Send notifications to trusted auth clients about org data changes. @@ -100,7 +102,7 @@ def notify_org_data_changed(org, user, changes, team=None): org to find apps that need to be notified. """ client_users = {} - for token in AuthToken.all(users=org.admin_users): + for token in AuthToken.all(accounts=org.admin_users): if ( token.auth_client.trusted and token.is_valid() @@ -109,7 +111,7 @@ def notify_org_data_changed(org, user, changes, team=None): ) and token.auth_client.notification_uri ): - client_users.setdefault(token.auth_client, []).append(token.user) + client_users.setdefault(token.auth_client, []).append(token.effective_user) # Now we have a list of clients to notify and a list of users to notify them with for auth_client, users in client_users.items(): if user is not None and user in users: @@ -131,8 +133,8 @@ def notify_org_data_changed(org, user, changes, team=None): @team_data_changed.connect -def notify_team_data_changed(team, user, changes): +def notify_team_data_changed(team: Team, user: Account, changes) -> None: """Notify :func:`notify_org_data_changed` for changes to the team.""" notify_org_data_changed( - team.organization, user=user, changes=['team-' + c for c in changes], team=team + team.account, user=user, changes=['team-' + c for c in changes], team=team ) diff --git a/funnel/views/comment.py b/funnel/views/comment.py index 8a2ebf22a..0d0d82a4b 100644 --- a/funnel/views/comment.py +++ b/funnel/views/comment.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional, Union - from flask import flash, request, url_for from baseframe import _, forms @@ -22,6 +20,7 @@ from .. import app from ..forms import CommentForm, CommentsetSubscribeForm from ..models import ( + Account, Comment, CommentModeratorReport, CommentReplyNotification, @@ -31,7 +30,6 @@ NewCommentNotification, Project, Proposal, - User, db, sa, ) @@ -46,22 +44,22 @@ @project_role_change.connect def update_project_commentset_membership( - project: Project, actor: User, user: User + project: Project, actor: Account, user: Account ) -> None: if 'participant' in project.roles_for(user): - project.commentset.add_subscriber(actor=actor, user=user) + project.commentset.add_subscriber(actor=actor, member=user) else: - project.commentset.remove_subscriber(actor=actor, user=user) + project.commentset.remove_subscriber(actor=actor, member=user) @proposal_role_change.connect def update_proposal_commentset_membership( - proposal: Proposal, actor: User, user: User + proposal: Proposal, actor: Account, user: Account ) -> None: if 'submitter' in proposal.roles_for(user): - proposal.commentset.add_subscriber(actor=actor, user=user) + proposal.commentset.add_subscriber(actor=actor, member=user) else: - proposal.commentset.remove_subscriber(actor=actor, user=user) + proposal.commentset.remove_subscriber(actor=actor, member=user) @Comment.views('url') @@ -94,7 +92,7 @@ def parent_comments_url(obj): @Commentset.views('last_comment', cached_property=True) -def last_comment(obj: Commentset) -> Optional[Comment]: +def last_comment(obj: Commentset) -> Comment | None: comment = obj.last_comment if comment: return comment.current_access(datasets=('primary', 'related')) @@ -150,18 +148,18 @@ def view(self, page: int = 1, per_page: int = 20) -> ReturnRenderWith: def do_post_comment( commentset: Commentset, - actor: User, + actor: Account, message: str, - in_reply_to: Optional[Comment] = None, + in_reply_to: Comment | None = None, ) -> Comment: """Support function for posting a comment and updating a subscription.""" comment = commentset.post_comment( actor=actor, message=message, in_reply_to=in_reply_to ) if commentset.current_roles.document_subscriber: - commentset.update_last_seen_at(user=actor) + commentset.update_last_seen_at(member=actor) else: - commentset.add_subscriber(actor=actor, user=actor) + commentset.add_subscriber(actor=actor, member=actor) db.session.commit() return comment @@ -174,7 +172,7 @@ class CommentsetView(UrlForView, ModelView): route_model_map = {'commentset': 'uuid_b58'} obj: Commentset - def loader(self, commentset) -> Commentset: + def loader(self, commentset: str) -> Commentset: return Commentset.query.filter(Commentset.uuid_b58 == commentset).one_or_404() @route('', methods=['GET']) @@ -228,14 +226,18 @@ def subscribe(self) -> ReturnView: subscribe_form.form_nonce.data = subscribe_form.form_nonce.default() if subscribe_form.validate_on_submit(): if subscribe_form.subscribe.data: - self.obj.add_subscriber(actor=current_auth.user, user=current_auth.user) + self.obj.add_subscriber( + actor=current_auth.user, member=current_auth.user + ) db.session.commit() return { 'status': 'ok', 'message': _("You will be notified of new comments"), 'form_nonce': subscribe_form.form_nonce.data, } - self.obj.remove_subscriber(actor=current_auth.user, user=current_auth.user) + self.obj.remove_subscriber( + actor=current_auth.user, member=current_auth.user + ) db.session.commit() return { 'status': 'ok', @@ -256,7 +258,7 @@ def subscribe(self) -> ReturnView: def update_last_seen_at(self) -> ReturnRenderWith: csrf_form = forms.Form() if csrf_form.validate_on_submit(): - self.obj.update_last_seen_at(user=current_auth.user) + self.obj.update_last_seen_at(member=current_auth.user) db.session.commit() return {'status': 'ok'} return { @@ -278,7 +280,7 @@ class CommentView(UrlForView, ModelView): route_model_map = {'commentset': 'commentset.uuid_b58', 'comment': 'uuid_b58'} obj: Comment - def loader(self, commentset, comment) -> Union[Comment, Commentset]: + def loader(self, commentset: str, comment: str) -> Comment | Commentset: comment = ( Comment.query.join(Commentset) .filter(Commentset.uuid_b58 == commentset, Comment.uuid_b58 == comment) @@ -292,7 +294,7 @@ def loader(self, commentset, comment) -> Union[Comment, Commentset]: ).one_or_404() return comment - def after_loader(self) -> Optional[ReturnView]: + def after_loader(self) -> ReturnView | None: if isinstance(self.obj, Commentset): flash( _("That comment could not be found. It may have been deleted"), 'error' diff --git a/funnel/views/contact.py b/funnel/views/contact.py index 96b397d4b..43064c7ac 100644 --- a/funnel/views/contact.py +++ b/funnel/views/contact.py @@ -2,14 +2,12 @@ from __future__ import annotations +import csv from datetime import datetime, timedelta from io import StringIO -from typing import Dict, Optional -import csv - -from sqlalchemy.exc import IntegrityError from flask import Response, current_app, render_template, request +from sqlalchemy.exc import IntegrityError from baseframe import _ from coaster.auth import current_auth @@ -19,11 +17,11 @@ from .. import app from ..models import ContactExchange, Project, TicketParticipant, db, sa from ..typing import ReturnRenderWith, ReturnView -from ..utils import abort_null, format_twitter_handle +from ..utils import format_twitter_handle from .login_session import requires_login -def contact_details(ticket_participant: TicketParticipant) -> Dict[str, Optional[str]]: +def contact_details(ticket_participant: TicketParticipant) -> dict[str, str | None]: return { 'fullname': ticket_participant.fullname, 'company': ticket_participant.company, @@ -143,7 +141,7 @@ def scan(self) -> ReturnView: @route('scan/connect', endpoint='scan_connect', methods=['POST']) @requires_login - @requestargs(('puk', abort_null), ('key', abort_null)) + @requestargs('puk', 'key') def connect(self, puk: str, key: str) -> ReturnView: """Verify a badge scan and create a contact.""" ticket_participant = TicketParticipant.query.filter_by(puk=puk, key=key).first() @@ -170,7 +168,7 @@ def connect(self, puk: str, key: str) -> ReturnView: try: contact_exchange = ContactExchange( - user=current_auth.actor, ticket_participant=ticket_participant + account=current_auth.actor, ticket_participant=ticket_participant ) db.session.add(contact_exchange) db.session.commit() diff --git a/funnel/views/decorators.py b/funnel/views/decorators.py index cbbb17973..b4d98767a 100644 --- a/funnel/views/decorators.py +++ b/funnel/views/decorators.py @@ -2,10 +2,11 @@ from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta from functools import wraps from hashlib import blake2b -from typing import Any, Callable, Dict, Optional, Set, Union, cast +from typing import cast from flask import Response, make_response, request, url_for @@ -13,28 +14,28 @@ from coaster.auth import current_auth from ..proxies import request_wants -from ..typing import ReturnDecorator, ReturnResponse, WrappedFunc +from ..typing import P, ReturnResponse, ReturnView, T from .helpers import compress_response, render_redirect -def xml_response(f: WrappedFunc) -> WrappedFunc: +def xml_response(f: Callable[P, str]) -> Callable[P, Response]: """Wrap the view result in a :class:`Response` with XML mimetype.""" @wraps(f) - def wrapper(*args, **kwargs) -> Response: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response: return Response(f(*args, **kwargs), mimetype='application/xml') - return cast(WrappedFunc, wrapper) + return wrapper def xhr_only( - redirect_to: Union[str, Callable[[], str], None] = None -) -> ReturnDecorator: + redirect_to: str | Callable[[], str] | None = None +) -> Callable[[Callable[P, T]], Callable[P, T | ReturnResponse]]: """Render a view only when it's an XHR request.""" - def decorator(f: WrappedFunc) -> WrappedFunc: + def decorator(f: Callable[P, T]) -> Callable[P, T | ReturnResponse]: @wraps(f) - def wrapper(*args, **kwargs) -> Any: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | ReturnResponse: if not request_wants.html_fragment: if redirect_to is None: destination = url_for('index') @@ -47,7 +48,7 @@ def wrapper(*args, **kwargs) -> Any: ) return f(*args, **kwargs) - return cast(WrappedFunc, wrapper) + return wrapper return decorator @@ -56,9 +57,9 @@ def etag_cache_for_user( identifier: str, view_version: int, timeout: int, - max_age: Optional[int] = None, - query_params: Optional[Set] = None, -) -> ReturnDecorator: + max_age: int | None = None, + query_params: set | None = None, +) -> Callable[[Callable[P, ReturnView]], Callable[P, Response]]: """ Cache and compress a response, and add an ETag header for browser cache. @@ -71,9 +72,9 @@ def etag_cache_for_user( if max_age is None: max_age = timeout - def decorator(f: WrappedFunc) -> Callable[..., Response]: + def decorator(f: Callable[P, ReturnView]) -> Callable[P, Response]: @wraps(f) - def wrapper(*args, **kwargs) -> ReturnResponse: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnResponse: # No ETag or cache storage if the request is not GET or HEAD if request.method not in ('GET', 'HEAD'): return f(*args, **kwargs) @@ -115,9 +116,7 @@ def wrapper(*args, **kwargs) -> ReturnResponse: # XXX: Typing for cache.get is incorrectly specified as returning # Optional[str] - cache_data: Optional[Dict] = cache.get( # type: ignore[assignment] - cache_key - ) + cache_data: dict | None = cache.get(cache_key) # type: ignore[assignment] response_data = None if cache_data: rhash_data = cache_data.get(rhash, {}) @@ -145,7 +144,7 @@ def wrapper(*args, **kwargs) -> ReturnResponse: ) if content_encoding: response.headers['Content-Encoding'] = content_encoding - response.vary.add('Accept-Encoding') # type: ignore[union-attr] + response.vary.add('Accept-Encoding') else: # 3b. If the cache was unusable (missing, malformed), call the view to # to get a fresh response and put it in the cache. @@ -183,6 +182,6 @@ def wrapper(*args, **kwargs) -> ReturnResponse: return response.make_conditional(request) - return cast(WrappedFunc, wrapper) + return wrapper return decorator diff --git a/funnel/views/email.py b/funnel/views/email.py index 3a3b233d0..4bf33c5b1 100644 --- a/funnel/views/email.py +++ b/funnel/views/email.py @@ -6,32 +6,32 @@ from baseframe import _ -from ..models import User, UserEmail +from ..models import Account, AccountEmailClaim from ..transports.email import jsonld_confirm_action, jsonld_view_action, send_email -def send_email_verify_link(useremail: UserEmail) -> str: +def send_email_verify_link(emailclaim: AccountEmailClaim) -> str: """Mail a verification link to the user.""" subject = _("Verify your email address") url = url_for( - 'confirm_email', + 'confirm_email_legacy', _external=True, - email_hash=useremail.email_address.email_hash, - secret=useremail.verification_code, + email_hash=emailclaim.email_address.email_hash, + secret=emailclaim.verification_code, utm_medium='email', - utm_campaign='verify', + utm_source='account-verify', ) jsonld = jsonld_confirm_action(subject, url, _("Verify email address")) content = render_template( 'email_account_verify.html.jinja2', - fullname=useremail.user.fullname, + fullname=emailclaim.account.title, url=url, jsonld=jsonld, ) - return send_email(subject, [(useremail.user.fullname, useremail.email)], content) + return send_email(subject, [(emailclaim.account.title, emailclaim.email)], content) -def send_password_reset_link(email: str, user: User, otp: str, token: str) -> str: +def send_password_reset_link(email: str, user: Account, otp: str, token: str) -> str: """Mail a password reset OTP and link to the user.""" subject = _("Reset your password - OTP {otp}").format(otp=otp) url = url_for( @@ -39,12 +39,12 @@ def send_password_reset_link(email: str, user: User, otp: str, token: str) -> st _external=True, token=token, utm_medium='email', - utm_campaign='reset', + utm_source='account-reset', ) jsonld = jsonld_view_action(subject, url, _("Reset password")) content = render_template( 'email_account_reset.html.jinja2', - fullname=user.fullname, + fullname=user.title, url=url, jsonld=jsonld, otp=otp, diff --git a/funnel/views/helpers.py b/funnel/views/helpers.py index bc3ce5f97..76fee10c3 100644 --- a/funnel/views/helpers.py +++ b/funnel/views/helpers.py @@ -2,16 +2,18 @@ from __future__ import annotations +import gzip +import zlib from base64 import urlsafe_b64encode +from collections.abc import Callable from contextlib import nullcontext from datetime import datetime, timedelta from hashlib import blake2b from os import urandom -from typing import Any, Callable, Dict, Optional, Tuple, Union -from urllib.parse import unquote, urljoin, urlsplit -import gzip -import zlib +from typing import Any +from urllib.parse import quote, unquote, urljoin, urlsplit +import brotli from flask import ( Flask, Response, @@ -25,23 +27,18 @@ session, url_for, ) +from furl import furl +from pytz import common_timezones, timezone as pytz_timezone, utc from werkzeug.exceptions import MethodNotAllowed, NotFound from werkzeug.routing import BuildError, RequestRedirect -from werkzeug.urls import url_quote - -from furl import furl -from pytz import common_timezones -from pytz import timezone as pytz_timezone -from pytz import utc -import brotli from baseframe import cache, statsd from coaster.sqlalchemy import RoleMixin from coaster.utils import utcnow -from .. import app, built_assets, shortlinkapp +from .. import app, shortlinkapp from ..forms import supported_locales -from ..models import Shortlink, User, db, profanity +from ..models import Account, Shortlink, db, profanity from ..proxies import request_wants from ..typing import ResponseType, ReturnResponse, ReturnView @@ -55,7 +52,7 @@ # --- Classes -------------------------------------------------------------------------- -class SessionTimeouts(Dict[str, timedelta]): +class SessionTimeouts(dict[str, timedelta]): """ Singleton dictionary that aids tracking timestamps in session. @@ -76,12 +73,12 @@ def __setitem__(self, key: str, value: timedelta) -> None: self.keys_at.add(f'{key}_at') super().__setitem__(key, value) - def __delitem__(self, key) -> None: + def __delitem__(self, key: str) -> None: """Remove a value from the dictionary.""" self.keys_at.remove(f'{key}_at') super().__delitem__(key) - def has_intersection(self, other): + def has_intersection(self, other: Any) -> bool: """Check for intersection with other dictionary-like object.""" okeys = other.keys() return not (self.keys_at.isdisjoint(okeys) and self.keys().isdisjoint(okeys)) @@ -100,7 +97,7 @@ def app_context(): return app.app_context() -def str_pw_set_at(user: User) -> str: +def str_pw_set_at(user: Account) -> str: """Render user.pw_set_at as a string, for comparison.""" if user.pw_set_at is not None: return user.pw_set_at.astimezone(utc).replace(microsecond=0).isoformat() @@ -117,8 +114,8 @@ def app_url_for( endpoint: str, _external: bool = True, _method: str = 'GET', - _anchor: Optional[str] = None, - _scheme: Optional[str] = None, + _anchor: str | None = None, + _scheme: str | None = None, **values: str, ) -> str: """ @@ -130,9 +127,8 @@ def app_url_for( The provided app must have `SERVER_NAME` in its config for URL construction to work. """ - if ( # pylint: disable=protected-access - current_app and current_app._get_current_object() is target_app - ): + # pylint: disable=protected-access + if current_app and current_app._get_current_object() is target_app: return url_for( endpoint, _external=_external, @@ -156,11 +152,11 @@ def app_url_for( if old_scheme is not None: url_adapter.url_scheme = old_scheme if _anchor: - result += f'#{url_quote(_anchor)}' + result += f'#{quote(_anchor)}' return result -def validate_is_app_url(url: Union[str, furl], method: str = 'GET') -> bool: +def validate_is_app_url(url: str | furl, method: str = 'GET') -> bool: """Confirm if an external URL is served by the current app (runtime-only).""" # Parse or copy URL and remove username and password before further analysis parsed_url = furl(url).remove(username=True, password=True) @@ -211,7 +207,7 @@ def validate_is_app_url(url: Union[str, furl], method: str = 'GET') -> bool: while True: # Keep looping on redirects try: - return bool(adapter.match(parsed_url.path, method=method)) + return bool(adapter.match(str(parsed_url.path), method=method)) except RequestRedirect as exc: parsed_url = furl(exc.new_url) except (MethodNotAllowed, NotFound): @@ -238,12 +234,12 @@ def localize_date(date, from_tz=utc, to_tz=utc): return date -def get_scheme_netloc(uri: str) -> Tuple[str, str]: +def get_scheme_netloc(uri: str) -> tuple[str, str]: parsed_uri = urlsplit(uri) return (parsed_uri.scheme, parsed_uri.netloc) -def autoset_timezone_and_locale(user: User) -> None: +def autoset_timezone_and_locale(user: Account) -> None: # Set the user's timezone and locale automatically if required if ( user.auto_timezone @@ -265,8 +261,8 @@ def autoset_timezone_and_locale(user: User) -> None: def progressive_rate_limit_validator( - token: str, prev_token: Optional[str] -) -> Tuple[bool, bool]: + token: str, prev_token: str | None +) -> tuple[bool, bool]: """ Validate for :func:`validate_rate_limit` on autocomplete-type resources. @@ -295,13 +291,13 @@ def progressive_rate_limit_validator( return (True, False) -def validate_rate_limit( # pylint: disable=too-many-arguments +def validate_rate_limit( resource: str, identifier: str, attempts: int, timeout: int, - token: Optional[str] = None, - validator: Optional[Callable[[str, Optional[str]], Tuple[bool, bool]]] = None, + token: str | None = None, + validator: Callable[[str, str | None], tuple[bool, bool]] | None = None, ): """ Validate a rate limit on API-endpoint resources. @@ -336,7 +332,7 @@ def validate_rate_limit( # pylint: disable=too-many-arguments ) cache_key = f'rate_limit/v1/{resource}/{identifier}' # XXX: Typing for cache.get is incorrectly specified as returning Optional[str] - cache_value: Optional[Tuple[int, str]] = cache.get( # type: ignore[assignment] + cache_value: tuple[int, str] | None = cache.get( # type: ignore[assignment] cache_key ) if cache_value is None: @@ -424,7 +420,7 @@ def make_cached_token(payload: dict, timeout: int = 24 * 60 * 60) -> str: return token -def retrieve_cached_token(token: str) -> Optional[dict]: +def retrieve_cached_token(token: str) -> dict | None: """Retrieve cached data given a token generated using :func:`make_cached_token`.""" # XXX: Typing for cache.get is incorrectly specified as returning Optional[str] return cache.get(TEXT_TOKEN_PREFIX + token) # type: ignore[return-value] @@ -491,7 +487,7 @@ def compress_response(response: ResponseType) -> None: if algorithm is not None: response.set_data(compress(response.get_data(), algorithm)) response.headers['Content-Encoding'] = algorithm - response.vary.add('Accept-Encoding') # type: ignore[union-attr] + response.vary.add('Accept-Encoding') # --- Template helpers ----------------------------------------------------------------- @@ -509,7 +505,6 @@ def render_redirect(url: str, code: int = 303) -> ReturnResponse: return Response( render_template('redirect.html.jinja2', url=url), status=200, - headers={'HX-Redirect': url}, ) if request_wants.json: response = jsonify({'status': 'error', 'error': 'redirect', 'location': url}) @@ -519,7 +514,7 @@ def render_redirect(url: str, code: int = 303) -> ReturnResponse: return redirect(url, code) -def html_in_json(template: str) -> Dict[str, Union[str, Callable[[dict], ReturnView]]]: +def html_in_json(template: str) -> dict[str, str | Callable[[dict], ReturnView]]: """Render a HTML fragment in a JSON wrapper, for use with ``@render_with``.""" def render_json_with_status(kwargs) -> ReturnResponse: @@ -567,7 +562,7 @@ def cleanurl_filter(url): @app.template_filter('shortlink') -def shortlink(url: str, actor: Optional[User] = None, shorter: bool = True) -> str: +def shortlink(url: str, actor: Account | None = None, shorter: bool = True) -> str: """ Return a short link suitable for sharing, in a template filter. @@ -581,13 +576,17 @@ def shortlink(url: str, actor: Optional[User] = None, shorter: bool = True) -> s return app_url_for(shortlinkapp, 'link', name=sl.name, _external=True) -@app.context_processor -def template_context() -> Dict[str, Any]: - """Add template context items.""" - return {'built_asset': lambda assetname: built_assets[assetname]} +# --- Request/response handlers -------------------------------------------------------- -# --- Request/response handlers -------------------------------------------------------- +@app.before_request +def no_null_in_form(): + """Disallow NULL characters in any form submit (but don't scan file attachments).""" + if request.method == 'POST': + for values in request.form.listvalues(): + for each in values: + if each is not None and '\x00' in each: + abort(400) @app.after_request diff --git a/funnel/views/index.py b/funnel/views/index.py index 223c6d7f2..31d789c7f 100644 --- a/funnel/views/index.py +++ b/funnel/views/index.py @@ -2,19 +2,22 @@ from __future__ import annotations -from dataclasses import dataclass import os.path +from dataclasses import dataclass from flask import Response, g, render_template +from markupsafe import Markup -from baseframe import __ +from baseframe import _, __ from baseframe.filters import date_filter +from baseframe.forms import render_message from coaster.views import ClassView, render_with, requestargs, route from .. import app, pages from ..forms import SavedProjectForm -from ..models import Profile, Project, sa +from ..models import Account, Project, sa from ..typing import ReturnRenderWith, ReturnView +from .schedule import schedule_data, session_list_data @dataclass @@ -43,7 +46,7 @@ class IndexView(ClassView): @route('', endpoint='index') @render_with('index.html.jinja2') def home(self) -> ReturnRenderWith: - g.profile = None + g.account = None projects = Project.all_unsorted() # TODO: Move these queries into the Project class all_projects = ( @@ -54,7 +57,7 @@ def home(self) -> ReturnRenderWith: Project.state.UPCOMING, sa.and_( Project.start_at.is_(None), - Project.published_at.isnot(None), + Project.published_at.is_not(None), Project.site_featured.is_(True), ), ), @@ -71,7 +74,7 @@ def home(self) -> ReturnRenderWith: Project.state.LIVE, Project.state.UPCOMING, sa.and_( - Project.start_at.is_(None), Project.published_at.isnot(None) + Project.start_at.is_(None), Project.published_at.is_not(None) ), ), Project.site_featured.is_(True), @@ -80,6 +83,30 @@ def home(self) -> ReturnRenderWith: .limit(1) .first() ) + scheduled_sessions_list = ( + session_list_data( + featured_project.scheduled_sessions, with_modal_url='view' + ) + if featured_project + else None + ) + featured_project_venues = ( + [ + venue.current_access(datasets=('without_parent', 'related')) + for venue in featured_project.venues + ] + if featured_project + else None + ) + featured_project_schedule = ( + schedule_data( + featured_project, + with_slots=False, + scheduled_sessions=scheduled_sessions_list, + ) + if featured_project + else None + ) if featured_project in upcoming_projects: # if featured project is in upcoming projects, remove it from there and # pick one upcoming project from from all projects, only if @@ -92,6 +119,15 @@ def home(self) -> ReturnRenderWith: .order_by(Project.next_session_at.asc()) .all() ) + # Get featured accounts + featured_accounts = Account.query.filter( + Account.name_in(app.config['FEATURED_ACCOUNTS']) + ).all() + # This list will not be ordered, so we have to re-sort + featured_account_sort_key = { + _n.lower(): _i for _i, _n in enumerate(app.config['FEATURED_ACCOUNTS']) + } + featured_accounts.sort(key=lambda a: featured_account_sort_key[a.name.lower()]) return { 'all_projects': [ @@ -107,20 +143,16 @@ def home(self) -> ReturnRenderWith: for p in open_cfp_projects ], 'featured_project': ( - featured_project.access_for( - roles={'all'}, datasets=('primary', 'related') - ) + featured_project.current_access(datasets=('primary', 'related')) if featured_project else None ), - 'featured_profiles': [ - p.current_access(datasets=('primary', 'related')) - for p in Profile.query.filter( - Profile.is_verified.is_(True), - Profile.organization_id.isnot(None), - ) - .order_by(sa.func.random()) - .limit(6) + 'featured_project_venues': featured_project_venues, + 'featured_project_sessions': scheduled_sessions_list, + 'featured_project_schedule': featured_project_schedule, + 'featured_accounts': [ + p.access_for(roles={'all'}, datasets=('primary', 'related')) + for p in featured_accounts ], } @@ -132,7 +164,7 @@ def home(self) -> ReturnRenderWith: @requestargs(('page', int), ('per_page', int)) @render_with('past_projects_section.html.jinja2') def past_projects(page: int = 1, per_page: int = 10) -> ReturnView: - g.profile = None + g.account = None projects = Project.all_unsorted() pagination = ( projects.filter(Project.state.PAST) @@ -189,3 +221,26 @@ def opensearch() -> ReturnView: @app.route('/robots.txt') def robotstxt() -> ReturnView: return Response(render_template('robots.txt.jinja2'), mimetype='text/plain') + + +@app.route('/account/not-my-otp') +def not_my_otp() -> ReturnView: + """Show help page for OTP misuse.""" + return render_message( + title=_("Did not request an OTP?"), + message=Markup( + _( + "If you’ve received an OTP without requesting it, someone may have made" + " a typo in their own phone number and accidentally used yours. They" + " will not gain access to your account without the OTP.

      " + "However, if you suspect misbehaviour of any form, please report it" + " to us. Email:" + ' {email}, phone:' + ' {phone_formatted}.' + ).format( + email=app.config['SITE_SUPPORT_EMAIL'], + phone=app.config['SITE_SUPPORT_PHONE'], + phone_formatted=app.config['SITE_SUPPORT_PHONE_FORMATTED'], + ) + ), + ) diff --git a/funnel/views/jobs.py b/funnel/views/jobs.py index 559a29068..dc2e2e64a 100644 --- a/funnel/views/jobs.py +++ b/funnel/views/jobs.py @@ -3,11 +3,12 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Callable from functools import wraps - -from flask import g +from typing_extensions import Protocol import requests +from flask import g from baseframe import statsd @@ -24,16 +25,31 @@ db, ) from ..signals import emailaddress_refcount_dropping, phonenumber_refcount_dropping -from ..typing import ResponseType, ReturnDecorator, WrappedFunc +from ..typing import P, ResponseType, T_co from .helpers import app_context -def rqjob(queue: str = 'funnel', **rqargs) -> ReturnDecorator: +class RqJobProtocol(Protocol[P, T_co]): + """Protocol for an RQ job function.""" + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T_co: + ... + + # TODO: Replace return type with job id type + def queue(self, *args: P.args, **kwargs: P.kwargs) -> None: + ... + + # TODO: Add other methods and attrs (queue_name, schedule, cron, ...) + + +def rqjob( + queue: str = 'funnel', **rqargs +) -> Callable[[Callable[P, T_co]], RqJobProtocol[P, T_co]]: """Decorate an RQ job with app context.""" - def decorator(f: WrappedFunc): + def decorator(f: Callable[P, T_co]) -> RqJobProtocol[P, T_co]: @wraps(f) - def wrapper(*args, **kwargs): + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T_co: with app_context(): return f(*args, **kwargs) @@ -43,7 +59,7 @@ def wrapper(*args, **kwargs): @rqjob() -def import_tickets(ticket_client_id): +def import_tickets(ticket_client_id: int) -> None: """Import tickets from Boxoffice.""" ticket_client = TicketClient.query.get(ticket_client_id) if ticket_client is not None: @@ -61,7 +77,7 @@ def import_tickets(ticket_client_id): @rqjob() -def tag_locations(project_id): +def tag_locations(project_id: int) -> None: """ Tag a project with geoname locations. @@ -173,11 +189,11 @@ def forget_email(email_hash: str) -> None: @rqjob() -def forget_phone(phone_hash) -> None: +def forget_phone(phone_hash: str) -> None: """Remove a phone number if it has no inbound references.""" phone_number = PhoneNumber.get(phone_hash=phone_hash) if phone_number is not None and phone_number.refcount() == 0: app.logger.info("Forgetting phone number with hash %s", phone_hash) - phone_number.number = None + phone_number.mark_forgotten() db.session.commit() statsd.incr('phone_number.forgotten') diff --git a/funnel/views/label.py b/funnel/views/label.py index 520655995..962c468fe 100644 --- a/funnel/views/label.py +++ b/funnel/views/label.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from flask import flash, request from werkzeug.datastructures import MultiDict @@ -13,16 +11,15 @@ from .. import app from ..forms import LabelForm, LabelOptionForm -from ..models import Label, Profile, Project, db, sa +from ..models import Account, Label, Project, db from ..typing import ReturnRenderWith, ReturnView -from ..utils import abort_null from .helpers import render_redirect from .login_session import requires_login, requires_sudo -from .mixins import ProfileCheckMixin, ProjectViewMixin +from .mixins import AccountCheckMixin, ProjectViewMixin @Project.views('label') -@route('///labels') +@route('///labels') class ProjectLabelView(ProjectViewMixin, UrlForView, ModelView): @route('', methods=['GET', 'POST']) @render_with('labels.html.jinja2') @@ -31,7 +28,7 @@ class ProjectLabelView(ProjectViewMixin, UrlForView, ModelView): def labels(self) -> ReturnRenderWith: form = forms.Form() if form.validate_on_submit(): - namelist = [abort_null(x) for x in request.values.getlist('name')] + namelist = request.values.getlist('name') for idx, lname in enumerate(namelist, start=1): lbl = Label.query.filter_by(project=self.obj, name=lname).first() if lbl is not None: @@ -53,8 +50,8 @@ def new_label(self) -> ReturnRenderWith: # and those values are also available at `form.data`. # But in case there are options, the option values are in the list # in the order they appeared on the create form. - titlelist = [abort_null(x) for x in request.values.getlist('title')] - emojilist = [abort_null(x) for x in request.values.getlist('icon_emoji')] + titlelist = request.values.getlist('title') + emojilist = request.values.getlist('icon_emoji') # first values of both lists belong to the parent label titlelist.pop(0) emojilist.pop(0) @@ -72,8 +69,9 @@ def new_label(self) -> ReturnRenderWith: for title, emoji in zip(titlelist, emojilist): subform = LabelOptionForm( MultiDict({'title': title, 'icon_emoji': emoji}), + # parent form has valid CSRF token meta={'csrf': False}, - ) # parent form has valid CSRF token + ) if not subform.validate(): flash( @@ -102,31 +100,29 @@ def new_label(self) -> ReturnRenderWith: @Label.views('main') -@route('///labels/
      NameTicketsEmailCompanyActions{% trans %}Name{% endtrans %}{% trans %}Tickets{% endtrans %}{% trans %}Email{% endtrans %}{% trans %}Company{% endtrans %}{% trans %}Actions{% endtrans %}
      @@ -56,7 +56,7 @@ document = """

      Tables

      -

      Right aligned columns #

      +

      Right aligned columns

      diff --git a/tests/unit/utils/markdown/data/tabs.toml b/tests/unit/utils/markdown/data/tabs.toml new file mode 100644 index 000000000..540ec8d7f --- /dev/null +++ b/tests/unit/utils/markdown/data/tabs.toml @@ -0,0 +1,932 @@ +markdown = """ +## Tabs using containers plugin +Let us see if we can use the containers plugin to render tabs. + +:::: tab Code +**The next tab has a blank title. +The tab for it should render in the format "Tab ".** + +This tab contains code! +::: tab Javascript +```js +let x = 10000; +sleep(x); +``` +::: +::: tab Python +```python +def sum(a, b): + return a['quantity'] + b['quantity'] +print(sum({'quantity': 5}, {'quantity': 10})) +``` +::: +::: tab Markdown +``` markdown +**Here is bold markdown.** +### Heading +\\::: should render three `:` characters. +\\``` should render three \\` characters. +There's a list below: +- Item 1 +- Item 2 +``` +::: +:::: +:::: tab +What to do with tabs without titles? I presume, we should be ignoring them completely. +:::: +:::: tab Embeds +::: tab Markmap +```{markmap} + +# Digital Identifiers and Rights + +## Community and Outreach + +- Experts +- Grant organizations +- IT ministries +- Journalism schools +- Journalists and reporters +- Partnerships + - Patrons + - Sponsors +- Policymakers +- Rights activists +- Startups +- Thinktanks +- Venture capital +- Volunteers + +## Domains + +- Border controls +- Citizenship +- Digital data trusts +- FinTech +- Government +- Health services delivery +- Hospitality +- Law enforcement +- Online retail and commerce +- Smart automation +- Social media +- Travel and tourism + +## Location + +- International +- Local or domestic +- Transit + +## Output and Outcomes + +- Best practices guide for product/service development +- Conference +- Conversations (eg. Twitter Spaces) +- Masterclass webinars +- Proceedings (talk playlist) +- Reports +- Review of Policies + +## Themes + +### Digital Identity + +- Anonymity +- Architecture of digital trust +- Control and ownership +- Identity and identifier models +- Inclusion and exclusion +- Portability +- Principles +- Regulations +- Reputation +- Rights and agency +- Trust framework +- Verifiability +- Vulnerable communities + +### Digital Rights + +- Current state across region +- Harms +- Emerging regulatory requirements +- Web 3.0 and decentralization +- Naturalization + +## Streams + +- Banking and finance +- Data exchange and interoperability +- Data governance models +- Data markets +- Digital identifiers and identity systems +- Digital public goods +- Digital public services +- Humanitarian activity and aid +- Identity ecosystems +- Innovation incubation incentives + - Public investment + - Private capital +- Local regulations and laws +- National policies +- Public health services +- Records (birth, death, land etc) +``` +::: +::: tab Vega +```{vega-lite} +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "A population pyramid for the US in 2000.", + "data": { "url": "https://vega.github.io/vega-lite/examples/data/population.json"}, + "transform": [ + {"filter": "datum.year == 2000"}, + {"calculate": "datum.sex == 2 ? 'Female' : 'Male'", "as": "gender"} + ], + "spacing": 0, + "hconcat": [{ + "transform": [{ + "filter": {"field": "gender", "equal": "Female"} + }], + "title": "Female", + "mark": "bar", + "encoding": { + "y": { + "field": "age", "axis": null, "sort": "descending" + }, + "x": { + "aggregate": "sum", "field": "people", + "title": "population", + "axis": {"format": "s"}, + "sort": "descending" + }, + "color": { + "field": "gender", + "scale": {"range": ["#675193", "#ca8861"]}, + "legend": null + } + } + }, { + "width": 20, + "view": {"stroke": null}, + "mark": { + "type": "text", + "align": "center" + }, + "encoding": { + "y": {"field": "age", "type": "ordinal", "axis": null, "sort": "descending"}, + "text": {"field": "age", "type": "quantitative"} + } + }, { + "transform": [{ + "filter": {"field": "gender", "equal": "Male"} + }], + "title": "Male", + "mark": "bar", + "encoding": { + "y": { + "field": "age", "title": null, + "axis": null, "sort": "descending" + }, + "x": { + "aggregate": "sum", "field": "people", + "title": "population", + "axis": {"format": "s"} + }, + "color": { + "field": "gender", + "legend": null + } + } + }], + "config": { + "view": {"stroke": null}, + "axis": {"grid": false} + } +} + +``` +::: +::: tab Mermaid +``` {mermaid} +sequenceDiagram + Alice->>+John: Hello John, how are you? + Alice->>+John: John, can you hear me? + John-->>-Alice: Hi Alice, I can hear you! + John-->>-Alice: I feel great! +``` +::: +:::: +:::: tab Table Tab +This tab is going to have a table! +| Heading 1 | Heading 2 | +| --------- | --------- | +| Content 1 | Content 2 | + +> Well if you really want, you can also have some other content! Like a blockquote, maybe? + +Perhaps a list? + +1. One with some order in it! +1. With multiple items, that too within the tab! + 1. Which is also nested ;) + 2. It could have multiple sub items. + 3. That are more than 2! +3. Finally, the list ends at top level. +:::: +""" + +[config] +profiles = [ "basic", "document",] + +[config.custom_profiles.tabs] +preset = "default" +plugins = ["tab_container"] + +[expected_output] +basic = """

      Tabs using containers plugin

      +

      Let us see if we can use the containers plugin to render tabs.

      +

      :::: tab Code
      +The next tab has a blank title.
      +The tab for it should render in the format "Tab <n>".

      +

      This tab contains code!
      +::: tab Javascript

      +
      let x = 10000;
      +sleep(x);
      +
      +

      :::
      +::: tab Python

      +
      def sum(a, b):
      +    return a['quantity'] + b['quantity']
      +print(sum({'quantity': 5}, {'quantity': 10}))
      +
      +

      :::
      +::: tab Markdown

      +
      **Here is bold markdown.**
      +### Heading
      +\\::: should render three `:` characters.
      +\\``` should render three \\` characters.
      +There's a list below:
      +- Item 1
      +- Item 2
      +
      +

      :::
      +::::
      +:::: tab
      +What to do with tabs without titles? I presume, we should be ignoring them completely.
      +::::
      +:::: tab Embeds
      +::: tab Markmap

      +
      
      +# Digital Identifiers and Rights
      +
      +## Community and Outreach
      +
      +- Experts
      +- Grant organizations
      +- IT ministries
      +- Journalism schools
      +- Journalists and reporters
      +- Partnerships
      +  - Patrons
      +  - Sponsors
      +- Policymakers
      +- Rights activists
      +- Startups
      +- Thinktanks
      +- Venture capital
      +- Volunteers
      +
      +## Domains
      +
      +- Border controls
      +- Citizenship
      +- Digital data trusts
      +- FinTech
      +- Government
      +- Health services delivery
      +- Hospitality
      +- Law enforcement
      +- Online retail and commerce
      +- Smart automation
      +- Social media
      +- Travel and tourism
      +
      +## Location
      +
      +- International
      +- Local or domestic
      +- Transit
      +
      +## Output and Outcomes
      +
      +- Best practices guide for product/service development
      +- Conference
      +- Conversations (eg. Twitter Spaces)
      +- Masterclass webinars
      +- Proceedings (talk playlist)
      +- Reports
      +- Review of Policies
      +
      +## Themes
      +
      +### Digital Identity
      +
      +- Anonymity
      +- Architecture of digital trust
      +- Control and ownership
      +- Identity and identifier models
      +- Inclusion and exclusion
      +- Portability
      +- Principles
      +- Regulations
      +- Reputation
      +- Rights and agency
      +- Trust framework
      +- Verifiability
      +- Vulnerable communities
      +
      +### Digital Rights
      +
      +- Current state across region
      +- Harms
      +- Emerging regulatory requirements
      +- Web 3.0 and decentralization
      +- Naturalization
      +
      +## Streams
      +
      +- Banking and finance
      +- Data exchange and interoperability
      +- Data governance models
      +- Data markets
      +- Digital identifiers and identity systems
      +- Digital public goods
      +- Digital public services
      +- Humanitarian activity and aid
      +- Identity ecosystems
      +- Innovation incubation incentives
      +  - Public investment
      +  - Private capital
      +- Local regulations and laws
      +- National policies
      +- Public health services
      +- Records (birth, death, land etc)
      +
      +

      :::
      +::: tab Vega

      +
      {
      +  "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
      +  "description": "A population pyramid for the US in 2000.",
      +  "data": { "url": "https://vega.github.io/vega-lite/examples/data/population.json"},
      +  "transform": [
      +    {"filter": "datum.year == 2000"},
      +    {"calculate": "datum.sex == 2 ? 'Female' : 'Male'", "as": "gender"}
      +  ],
      +  "spacing": 0,
      +  "hconcat": [{
      +    "transform": [{
      +      "filter": {"field": "gender", "equal": "Female"}
      +    }],
      +    "title": "Female",
      +    "mark": "bar",
      +    "encoding": {
      +      "y": {
      +        "field": "age", "axis": null, "sort": "descending"
      +      },
      +      "x": {
      +        "aggregate": "sum", "field": "people",
      +        "title": "population",
      +        "axis": {"format": "s"},
      +        "sort": "descending"
      +      },
      +      "color": {
      +        "field": "gender",
      +        "scale": {"range": ["#675193", "#ca8861"]},
      +        "legend": null
      +      }
      +    }
      +  }, {
      +    "width": 20,
      +    "view": {"stroke": null},
      +    "mark": {
      +      "type": "text",
      +      "align": "center"
      +    },
      +    "encoding": {
      +      "y": {"field": "age", "type": "ordinal", "axis": null, "sort": "descending"},
      +      "text": {"field": "age", "type": "quantitative"}
      +    }
      +  }, {
      +    "transform": [{
      +      "filter": {"field": "gender", "equal": "Male"}
      +    }],
      +    "title": "Male",
      +    "mark": "bar",
      +    "encoding": {
      +      "y": {
      +        "field": "age", "title": null,
      +        "axis": null, "sort": "descending"
      +      },
      +      "x": {
      +        "aggregate": "sum", "field": "people",
      +        "title": "population",
      +        "axis": {"format": "s"}
      +      },
      +      "color": {
      +        "field": "gender",
      +        "legend": null
      +      }
      +    }
      +  }],
      +  "config": {
      +    "view": {"stroke": null},
      +    "axis": {"grid": false}
      +  }
      +}
      +
      +
      +

      :::
      +::: tab Mermaid

      +
      sequenceDiagram
      +    Alice->>+John: Hello John, how are you?
      +    Alice->>+John: John, can you hear me?
      +    John-->>-Alice: Hi Alice, I can hear you!
      +    John-->>-Alice: I feel great!
      +
      +

      :::
      +::::
      +:::: tab Table Tab
      +This tab is going to have a table!
      +| Heading 1 | Heading 2 |
      +| --------- | --------- |
      +| Content 1 | Content 2 |

      +
      +

      Well if you really want, you can also have some other content! Like a blockquote, maybe?

      +
      +

      Perhaps a list?

      +
        +
      1. One with some order in it!
      2. +
      3. With multiple items, that too within the tab! +
          +
        1. Which is also nested ;)
        2. +
        3. It could have multiple sub items.
        4. +
        5. That are more than 2!
        6. +
        +
      4. +
      5. Finally, the list ends at top level.
        +::::
      6. +
      +""" +document = """

      Tabs using containers plugin

      +

      Let us see if we can use the containers plugin to render tabs.

      +

      The next tab has a blank title.
      +The tab for it should render in the format “Tab <n>”.

      +

      This tab contains code!

      +
      let x = 10000;
      +sleep(x);
      +
      +
      def sum(a, b):
      +    return a['quantity'] + b['quantity']
      +print(sum({'quantity': 5}, {'quantity': 10}))
      +
      +
      **Here is bold markdown.**
      +### Heading
      +\\::: should render three `:` characters.
      +\\``` should render three \\` characters.
      +There's a list below:
      +- Item 1
      +- Item 2
      +
      +

      What to do with tabs without titles? I presume, we should be ignoring them completely.

      +
      Mindmap
      +# Digital Identifiers and Rights
      +
      +## Community and Outreach
      +
      +- Experts
      +- Grant organizations
      +- IT ministries
      +- Journalism schools
      +- Journalists and reporters
      +- Partnerships
      +- Patrons
      +- Sponsors
      +- Policymakers
      +- Rights activists
      +- Startups
      +- Thinktanks
      +- Venture capital
      +- Volunteers
      +
      +## Domains
      +
      +- Border controls
      +- Citizenship
      +- Digital data trusts
      +- FinTech
      +- Government
      +- Health services delivery
      +- Hospitality
      +- Law enforcement
      +- Online retail and commerce
      +- Smart automation
      +- Social media
      +- Travel and tourism
      +
      +## Location
      +
      +- International
      +- Local or domestic
      +- Transit
      +
      +## Output and Outcomes
      +
      +- Best practices guide for product/service development
      +- Conference
      +- Conversations (eg. Twitter Spaces)
      +- Masterclass webinars
      +- Proceedings (talk playlist)
      +- Reports
      +- Review of Policies
      +
      +## Themes
      +
      +### Digital Identity
      +
      +- Anonymity
      +- Architecture of digital trust
      +- Control and ownership
      +- Identity and identifier models
      +- Inclusion and exclusion
      +- Portability
      +- Principles
      +- Regulations
      +- Reputation
      +- Rights and agency
      +- Trust framework
      +- Verifiability
      +- Vulnerable communities
      +
      +### Digital Rights
      +
      +- Current state across region
      +- Harms
      +- Emerging regulatory requirements
      +- Web 3.0 and decentralization
      +- Naturalization
      +
      +## Streams
      +
      +- Banking and finance
      +- Data exchange and interoperability
      +- Data governance models
      +- Data markets
      +- Digital identifiers and identity systems
      +- Digital public goods
      +- Digital public services
      +- Humanitarian activity and aid
      +- Identity ecosystems
      +- Innovation incubation incentives
      +- Public investment
      +- Private capital
      +- Local regulations and laws
      +- National policies
      +- Public health services
      +- Records (birth, death, land etc)
      +
      +
      Visualization
      {
      +"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
      +"description": "A population pyramid for the US in 2000.",
      +"data": { "url": "https://vega.github.io/vega-lite/examples/data/population.json"},
      +"transform": [
      + {"filter": "datum.year == 2000"},
      + {"calculate": "datum.sex == 2 ? 'Female' : 'Male'", "as": "gender"}
      +],
      +"spacing": 0,
      +"hconcat": [{
      + "transform": [{
      +   "filter": {"field": "gender", "equal": "Female"}
      + }],
      + "title": "Female",
      + "mark": "bar",
      + "encoding": {
      +   "y": {
      +     "field": "age", "axis": null, "sort": "descending"
      +   },
      +   "x": {
      +     "aggregate": "sum", "field": "people",
      +     "title": "population",
      +     "axis": {"format": "s"},
      +     "sort": "descending"
      +   },
      +   "color": {
      +     "field": "gender",
      +     "scale": {"range": ["#675193", "#ca8861"]},
      +     "legend": null
      +   }
      + }
      +}, {
      + "width": 20,
      + "view": {"stroke": null},
      + "mark": {
      +   "type": "text",
      +   "align": "center"
      + },
      + "encoding": {
      +   "y": {"field": "age", "type": "ordinal", "axis": null, "sort": "descending"},
      +   "text": {"field": "age", "type": "quantitative"}
      + }
      +}, {
      + "transform": [{
      +   "filter": {"field": "gender", "equal": "Male"}
      + }],
      + "title": "Male",
      + "mark": "bar",
      + "encoding": {
      +   "y": {
      +     "field": "age", "title": null,
      +     "axis": null, "sort": "descending"
      +   },
      +   "x": {
      +     "aggregate": "sum", "field": "people",
      +     "title": "population",
      +     "axis": {"format": "s"}
      +   },
      +   "color": {
      +     "field": "gender",
      +     "legend": null
      +   }
      + }
      +}],
      +"config": {
      + "view": {"stroke": null},
      + "axis": {"grid": false}
      +}
      +}
      +
      +
      +
      Visualization
      sequenceDiagram
      + Alice->>+John: Hello John, how are you?
      + Alice->>+John: John, can you hear me?
      + John-->>-Alice: Hi Alice, I can hear you!
      + John-->>-Alice: I feel great!
      +
      +

      This tab is going to have a table!

      +
      + + + + + + + + + + + + +
      Heading 1Heading 2
      Content 1Content 2
      +
      +

      Well if you really want, you can also have some other content! Like a blockquote, maybe?

      +
      +

      Perhaps a list?

      +
        +
      1. One with some order in it!
      2. +
      3. With multiple items, that too within the tab! +
          +
        1. Which is also nested ;)
        2. +
        3. It could have multiple sub items.
        4. +
        5. That are more than 2!
        6. +
        +
      4. +
      5. Finally, the list ends at top level.
      6. +
      +
    """ +tabs = """

    Tabs using containers plugin

    +

    Let us see if we can use the containers plugin to render tabs.

    +

    The next tab has a blank title. +The tab for it should render in the format "Tab <n>".

    +

    This tab contains code!

    +
    let x = 10000;
    +sleep(x);
    +
    +
    def sum(a, b):
    +    return a['quantity'] + b['quantity']
    +print(sum({'quantity': 5}, {'quantity': 10}))
    +
    +
    **Here is bold markdown.**
    +### Heading
    +\\::: should render three `:` characters.
    +\\``` should render three \\` characters.
    +There's a list below:
    +- Item 1
    +- Item 2
    +
    +

    What to do with tabs without titles? I presume, we should be ignoring them completely.

    +
    
    +# Digital Identifiers and Rights
    +
    +## Community and Outreach
    +
    +- Experts
    +- Grant organizations
    +- IT ministries
    +- Journalism schools
    +- Journalists and reporters
    +- Partnerships
    +  - Patrons
    +  - Sponsors
    +- Policymakers
    +- Rights activists
    +- Startups
    +- Thinktanks
    +- Venture capital
    +- Volunteers
    +
    +## Domains
    +
    +- Border controls
    +- Citizenship
    +- Digital data trusts
    +- FinTech
    +- Government
    +- Health services delivery
    +- Hospitality
    +- Law enforcement
    +- Online retail and commerce
    +- Smart automation
    +- Social media
    +- Travel and tourism
    +
    +## Location
    +
    +- International
    +- Local or domestic
    +- Transit
    +
    +## Output and Outcomes
    +
    +- Best practices guide for product/service development
    +- Conference
    +- Conversations (eg. Twitter Spaces)
    +- Masterclass webinars
    +- Proceedings (talk playlist)
    +- Reports
    +- Review of Policies
    +
    +## Themes
    +
    +### Digital Identity
    +
    +- Anonymity
    +- Architecture of digital trust
    +- Control and ownership
    +- Identity and identifier models
    +- Inclusion and exclusion
    +- Portability
    +- Principles
    +- Regulations
    +- Reputation
    +- Rights and agency
    +- Trust framework
    +- Verifiability
    +- Vulnerable communities
    +
    +### Digital Rights
    +
    +- Current state across region
    +- Harms
    +- Emerging regulatory requirements
    +- Web 3.0 and decentralization
    +- Naturalization
    +
    +## Streams
    +
    +- Banking and finance
    +- Data exchange and interoperability
    +- Data governance models
    +- Data markets
    +- Digital identifiers and identity systems
    +- Digital public goods
    +- Digital public services
    +- Humanitarian activity and aid
    +- Identity ecosystems
    +- Innovation incubation incentives
    +  - Public investment
    +  - Private capital
    +- Local regulations and laws
    +- National policies
    +- Public health services
    +- Records (birth, death, land etc)
    +
    +
    {
    +  "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
    +  "description": "A population pyramid for the US in 2000.",
    +  "data": { "url": "https://vega.github.io/vega-lite/examples/data/population.json"},
    +  "transform": [
    +    {"filter": "datum.year == 2000"},
    +    {"calculate": "datum.sex == 2 ? 'Female' : 'Male'", "as": "gender"}
    +  ],
    +  "spacing": 0,
    +  "hconcat": [{
    +    "transform": [{
    +      "filter": {"field": "gender", "equal": "Female"}
    +    }],
    +    "title": "Female",
    +    "mark": "bar",
    +    "encoding": {
    +      "y": {
    +        "field": "age", "axis": null, "sort": "descending"
    +      },
    +      "x": {
    +        "aggregate": "sum", "field": "people",
    +        "title": "population",
    +        "axis": {"format": "s"},
    +        "sort": "descending"
    +      },
    +      "color": {
    +        "field": "gender",
    +        "scale": {"range": ["#675193", "#ca8861"]},
    +        "legend": null
    +      }
    +    }
    +  }, {
    +    "width": 20,
    +    "view": {"stroke": null},
    +    "mark": {
    +      "type": "text",
    +      "align": "center"
    +    },
    +    "encoding": {
    +      "y": {"field": "age", "type": "ordinal", "axis": null, "sort": "descending"},
    +      "text": {"field": "age", "type": "quantitative"}
    +    }
    +  }, {
    +    "transform": [{
    +      "filter": {"field": "gender", "equal": "Male"}
    +    }],
    +    "title": "Male",
    +    "mark": "bar",
    +    "encoding": {
    +      "y": {
    +        "field": "age", "title": null,
    +        "axis": null, "sort": "descending"
    +      },
    +      "x": {
    +        "aggregate": "sum", "field": "people",
    +        "title": "population",
    +        "axis": {"format": "s"}
    +      },
    +      "color": {
    +        "field": "gender",
    +        "legend": null
    +      }
    +    }
    +  }],
    +  "config": {
    +    "view": {"stroke": null},
    +    "axis": {"grid": false}
    +  }
    +}
    +
    +
    +
    sequenceDiagram
    +    Alice->>+John: Hello John, how are you?
    +    Alice->>+John: John, can you hear me?
    +    John-->>-Alice: Hi Alice, I can hear you!
    +    John-->>-Alice: I feel great!
    +
    +

    This tab is going to have a table!

    + + + + + + + + + + + + + +
    Heading 1Heading 2
    Content 1Content 2
    +
    +

    Well if you really want, you can also have some other content! Like a blockquote, maybe?

    +
    +

    Perhaps a list?

    +
      +
    1. One with some order in it!
    2. +
    3. With multiple items, that too within the tab! +
        +
      1. Which is also nested ;)
      2. +
      3. It could have multiple sub items.
      4. +
      5. That are more than 2!
      6. +
      +
    4. +
    5. Finally, the list ends at top level.
    6. +
    +
    """ diff --git a/tests/unit/utils/markdown/data/vega-lite.toml b/tests/unit/utils/markdown/data/vega-lite.toml index 0175eb438..a2aed9168 100644 --- a/tests/unit/utils/markdown/data/vega-lite.toml +++ b/tests/unit/utils/markdown/data/vega-lite.toml @@ -354,9 +354,9 @@ markdown = """ [config] profiles = [ "basic", "document",] -[config.custom_profiles.vega-lite] +[config.custom_profiles.vega_lite] preset = "default" -plugins = [ "vega-lite",] +plugins = [ "vega_lite",] [expected_output] basic = """

    vega-lite tests

    @@ -695,8 +695,8 @@ basic = """

    vega-lite tests

    """ -document = """

    vega-lite tests #

    -

    Interactive Scatter Plot Matrix #

    +document = """

    vega-lite tests

    +

    Interactive Scatter Plot Matrix

    Visualization
    {
     "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
     "repeat": {
    @@ -747,7 +747,7 @@ document = """

    vega-lite tests

    -

    Population Pyramid #

    +

    Population Pyramid

    Visualization
    {
     "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
     "description": "A population pyramid for the US in 2000.",
    @@ -819,7 +819,7 @@ document = """

    vega-lite tests

    -

    Discretizing scales #

    +

    Discretizing scales

    Visualization
    {
     "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
     "description": "Horizontally concatenated charts that show different types of discretizing scales.",
    @@ -950,7 +950,7 @@ document = """

    vega-lite tests

    -

    Marginal Histograms #

    +

    Marginal Histograms

    Visualization
    {
     "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
     "data": {"url": "https://vega.github.io/vega-lite/examples/data/movies.json"},
    @@ -1007,7 +1007,7 @@ document = """

    vega-lite tests

    -

    Radial Plot #

    +

    Radial Plot

    Visualization
    {
     "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
     "description": "A simple radial chart with embedded data.",
    diff --git a/tests/unit/utils/markdown/test_markdown.py b/tests/unit/utils/markdown/markdown_test.py
    similarity index 73%
    rename from tests/unit/utils/markdown/test_markdown.py
    rename to tests/unit/utils/markdown/markdown_test.py
    index e55c52183..5d2c198dd 100644
    --- a/tests/unit/utils/markdown/test_markdown.py
    +++ b/tests/unit/utils/markdown/markdown_test.py
    @@ -1,10 +1,9 @@
     """Tests for markdown parser."""
    -# pylint: disable=too-many-arguments
     
     import warnings
     
    -from markupsafe import Markup
     import pytest
    +from markupsafe import Markup
     
     from funnel.utils.markdown import MarkdownConfig
     
    @@ -24,15 +23,12 @@ def test_markdown_blank() -> None:
         assert MarkdownConfig().render('') == blank_response
     
     
    -# def test_markdown_cases(
    -#    md_testname: str, md_configname: str,markdown_test_registry, fail_with_diff
    -# ) -> None:
     def test_markdown_cases(
         md_testname: str, md_configname: str, markdown_test_registry
     ) -> None:
         case = markdown_test_registry.test_case(md_testname, md_configname)
         if case.expected_output is None:
    -        warnings.warn(f'Expected output not generated for {case}')
    +        warnings.warn(f'Expected output not generated for {case}', stacklevel=1)
             pytest.skip(f'Expected output not generated for {case}')
     
         assert case.expected_output == case.output
    @@ -41,17 +37,8 @@ def test_markdown_cases(
         # fail_with_diff(case.expected_output, case.output)
     
     
    -@pytest.mark.update_markdown_data()
    -def test_markdown_update_output(pytestconfig, markdown_test_registry):
    -    """Update the expected output in all .toml files."""
    -    has_mark = pytestconfig.getoption('-m', default=None) == 'update_markdown_data'
    -    if not has_mark:
    -        pytest.skip('Skipping update of expected output of markdown test cases')
    -    markdown_test_registry.update_expected_output()
    -
    -
     @pytest.mark.debug_markdown_output()
    -def test_markdown_debug_output(pytestconfig, markdown_test_registry):
    +def test_markdown_debug_output(pytestconfig, markdown_test_registry) -> None:
         has_mark = pytestconfig.getoption('-m', default=None) == 'debug_markdown_output'
         if not has_mark:
             pytest.skip('Skipping update of debug output file for markdown test cases')
    diff --git a/tests/unit/utils/markdown_escape_test.py b/tests/unit/utils/markdown_escape_test.py
    new file mode 100644
    index 000000000..6f85623ff
    --- /dev/null
    +++ b/tests/unit/utils/markdown_escape_test.py
    @@ -0,0 +1,15 @@
    +"""Tests for MarkdownString and markdown_escape."""
    +
    +from funnel.utils import MarkdownString, markdown_escape
    +
    +
    +def test_markdown_escape() -> None:
    +    """Test that markdown_escape escapes Markdown punctuation (partial test)."""
    +    assert isinstance(markdown_escape(''), MarkdownString)
    +    assert markdown_escape('No escape') == 'No escape'
    +    assert (
    +        markdown_escape('This _has_ Markdown markup') == r'This \_has\_ Markdown markup'
    +    )
    +    mixed = 'This has **mixed** markup'
    +    assert markdown_escape(mixed) == r'This \has\<\/em\> \*\*mixed\*\* markup'
    +    assert markdown_escape(mixed).unescape() == mixed
    diff --git a/tests/unit/utils/test_misc.py b/tests/unit/utils/misc_test.py
    similarity index 99%
    rename from tests/unit/utils/test_misc.py
    rename to tests/unit/utils/misc_test.py
    index 8112fb128..e7aec43a0 100644
    --- a/tests/unit/utils/test_misc.py
    +++ b/tests/unit/utils/misc_test.py
    @@ -1,8 +1,7 @@
     """Tests for base utilities."""
     
    -from werkzeug.exceptions import BadRequest
    -
     import pytest
    +from werkzeug.exceptions import BadRequest
     
     from funnel import utils
     
    diff --git a/tests/unit/utils/test_mustache.py b/tests/unit/utils/mustache_test.py
    similarity index 83%
    rename from tests/unit/utils/test_mustache.py
    rename to tests/unit/utils/mustache_test.py
    index 89aec20a1..3503f445b 100644
    --- a/tests/unit/utils/test_mustache.py
    +++ b/tests/unit/utils/mustache_test.py
    @@ -1,11 +1,11 @@
    +"""Tests for Mustache templates on Markdown documents."""
    +# pylint: disable=not-callable
     # mypy: disable-error-code=index
    -"""Tests for the mustache template escaper."""
     
    -from typing import Dict, Tuple
     
     import pytest
     
    -from funnel.utils.markdown.base import MarkdownConfig
    +from funnel.utils.markdown import MarkdownConfig
     from funnel.utils.mustache import mustache_md
     
     test_data = {
    @@ -27,8 +27,9 @@
     }
     
     #: Dict of {test_name: (template, output)}
    -templates_and_output: Dict[str, Tuple[str, str]] = {}
    -config_template_output: Dict[str, Tuple[str, str, str]] = {}
    +templates_and_output: dict[str, tuple[str, str]] = {}
    +#: Dict of {test_name: (template, config_name, output)}
    +config_template_output: dict[str, tuple[str, str, str]] = {}
     
     templates_and_output['basic'] = (
         """
    @@ -86,9 +87,8 @@
         templates_and_output.values(),
         ids=templates_and_output.keys(),
     )
    -def test_mustache_md(template, expected_output):
    -    output = mustache_md(template, test_data)
    -    assert expected_output == output
    +def test_mustache_md(template: str, expected_output: str) -> None:
    +    assert mustache_md(template, test_data) == expected_output
     
     
     config_template_output['basic-basic'] = (
    @@ -119,17 +119,17 @@ def test_mustache_md(template, expected_output):
         """

    Name: Unseen
    Bold Name: **Unseen** University
    Organization: `Unseen` University, ~~Unknown~~Ankh-Morpork

    -

    Organization Details #

    +

    Organization Details

    Name: `Unseen` University
    City: ~~Unknown~~Ankh-Morpork

    -

    People #

    +

    People

    • Alberto Malich
    • Mustrum Ridcully (Archchancellor)
    • The Librarian
    • Ponder Stibbons
    -

    Vendors #

    +

    Vendors

    No vendors

    @@ -174,7 +174,8 @@ def test_mustache_md(template, expected_output): config_template_output.values(), ids=config_template_output.keys(), ) -def test_mustache_md_markdown(template, config, expected_output): - assert expected_output == MarkdownConfig.registry[config].render( - mustache_md(template, test_data) +def test_mustache_md_markdown(template: str, config: str, expected_output: str) -> None: + assert ( + MarkdownConfig.registry[config].render(mustache_md(template, test_data)) + == expected_output ) diff --git a/tests/unit/views/test_account_menu.py b/tests/unit/views/account_menu_test.py similarity index 94% rename from tests/unit/views/test_account_menu.py rename to tests/unit/views/account_menu_test.py index 4b340d7ec..82fa3f856 100644 --- a/tests/unit/views/test_account_menu.py +++ b/tests/unit/views/account_menu_test.py @@ -1,5 +1,4 @@ """Tests for account menu drop-down views.""" -# pylint: disable=too-many-arguments import time @@ -52,7 +51,7 @@ def test_recent_organization_memberships_count( ) # Most recently added org should be first in results. The `dbcommit` mark is # required to ensure this, as granted_at timestamp is set by the SQL transaction - assert result.recent[0].organization.name == org.name + assert result.recent[0].account.urlname == org.urlname assert len(result.recent) == returned_listed assert len(result.overflow) == returned_overflow assert result.extra_count == returned_extra_count diff --git a/tests/unit/views/test_account.py b/tests/unit/views/account_test.py similarity index 96% rename from tests/unit/views/test_account.py rename to tests/unit/views/account_test.py index 569d9eadf..f4ac03dd1 100644 --- a/tests/unit/views/test_account.py +++ b/tests/unit/views/account_test.py @@ -9,7 +9,6 @@ def test_username_available(db_session, client, user_rincewind, csrf_token) -> None: """Test the username availability endpoint.""" - db_session.commit() endpoint = '/api/1/account/username_available' # Does not support GET requests @@ -24,7 +23,7 @@ def test_username_available(db_session, client, user_rincewind, csrf_token) -> N # Valid usernames will return an ok response rv = client.post( endpoint, - data={'username': 'should-be-available', 'csrf_token': csrf_token}, + data={'username': 'should_be_available', 'csrf_token': csrf_token}, ) assert rv.status_code == 200 assert rv.get_json() == {'status': 'ok'} @@ -50,8 +49,8 @@ def test_username_available(db_session, client, user_rincewind, csrf_token) -> N assert rv.get_json() == { 'status': 'error', 'error': 'validation_failure', - 'error_description': "Usernames can only have alphabets, numbers and dashes" - " (except at the ends)", + 'error_description': "Usernames can only have alphabets, numbers and" + " underscores", } @@ -60,7 +59,7 @@ def test_username_available(db_session, client, user_rincewind, csrf_token) -> N PWNED_PASSWORD = "thisisone1" # nosec -@pytest.mark.remote_data() +@pytest.mark.enable_socket() def test_pwned_password(client, csrf_token, login, user_rincewind) -> None: """Pwned password validator will block attempt to use a compromised password.""" login.as_(user_rincewind) @@ -162,7 +161,10 @@ def test_pwned_password_mock_endpoint_down( 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X)' ' AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0' ' EdgiOS/100.1185.50 Mobile/15E148 Safari/605.1.15', - {'browser': 'Mobile Safari 15.0', 'os_device': 'Apple iPhone (iOS 15.6.1)'}, + { + 'browser': 'Edge Mobile 100.1185.50', + 'os_device': 'Apple iPhone (iOS 15.6.1)', + }, ), ( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; Xbox; Xbox One)' diff --git a/tests/unit/views/test_api_shortlink.py b/tests/unit/views/api_shortlink_test.py similarity index 81% rename from tests/unit/views/test_api_shortlink.py rename to tests/unit/views/api_shortlink_test.py index 92f53d64a..60176cba8 100644 --- a/tests/unit/views/test_api_shortlink.py +++ b/tests/unit/views/api_shortlink_test.py @@ -1,9 +1,9 @@ """Test shortlink API views.""" +# pylint: disable=redefined-outer-name +import pytest from flask import url_for - from furl import furl -import pytest from funnel import models @@ -17,7 +17,7 @@ def create_shortlink(app_context): @pytest.fixture() def user_rincewind_site_editor(db_session, user_rincewind): sm = models.SiteMembership( - user=user_rincewind, granted_by=user_rincewind, is_site_editor=True + member=user_rincewind, granted_by=user_rincewind, is_site_editor=True ) db_session.add(sm) db_session.commit() @@ -39,7 +39,7 @@ def test_create_invalid_shortlink( # A relative URL will be rejected rv = client.post( - create_shortlink, data={'url': user_rincewind.profile.url_for(_external=False)} + create_shortlink, data={'url': user_rincewind.url_for(_external=False)} ) assert rv.status_code == 422 assert rv.json['error'] == 'url_invalid' @@ -57,7 +57,7 @@ def test_create_shortlink(app, client, user_rincewind, create_shortlink) -> None """Creating a shortlink via API with valid data will pass.""" # A valid URL to an app path will be accepted rv = client.post( - create_shortlink, data={'url': user_rincewind.profile.url_for(_external=True)} + create_shortlink, data={'url': user_rincewind.url_for(_external=True)} ) assert rv.status_code == 201 sl1 = furl(rv.json['shortlink']) @@ -66,7 +66,7 @@ def test_create_shortlink(app, client, user_rincewind, create_shortlink) -> None # Asking for it again will return the same link rv = client.post( - create_shortlink, data={'url': user_rincewind.profile.url_for(_external=True)} + create_shortlink, data={'url': user_rincewind.url_for(_external=True)} ) assert rv.status_code == 200 sl2 = furl(rv.json['shortlink']) @@ -75,18 +75,14 @@ def test_create_shortlink(app, client, user_rincewind, create_shortlink) -> None # A valid URL can include extra query parameters rv = client.post( create_shortlink, - data={ - 'url': user_rincewind.profile.url_for( - _external=True, utm_campaign='webshare' - ) - }, + data={'url': user_rincewind.url_for(_external=True, utm_campaign='webshare')}, ) assert rv.status_code == 201 sl3 = furl(rv.json['shortlink']) assert sl3.netloc == app.config['SHORTLINK_DOMAIN'] assert len(str(sl3.path)) <= 5 # API defaults to the shorter form (max 4 chars) assert sl3.path != sl1.path # We got a different shortlink - assert rv.json['url'] == user_rincewind.profile.url_for( + assert rv.json['url'] == user_rincewind.url_for( _external=True, utm_campaign='webshare' ) @@ -94,7 +90,7 @@ def test_create_shortlink(app, client, user_rincewind, create_shortlink) -> None def test_create_shortlink_longer(app, client, user_rincewind, create_shortlink) -> None: rv = client.post( create_shortlink, - data={'url': user_rincewind.profile.url_for(_external=True), 'shorter': '0'}, + data={'url': user_rincewind.url_for(_external=True), 'shorter': '0'}, ) assert rv.status_code == 201 sl1 = furl(rv.json['shortlink']) @@ -109,7 +105,7 @@ def test_create_shortlink_name_unauthorized( rv = client.post( create_shortlink, data={ - 'url': user_rincewind.profile.url_for(_external=True), + 'url': user_rincewind.url_for(_external=True), 'name': 'rincewind', }, ) @@ -119,7 +115,7 @@ def test_create_shortlink_name_unauthorized( @pytest.mark.filterwarnings("ignore:New instance.*conflicts with persistent instance") @pytest.mark.usefixtures('user_rincewind_site_editor') -def test_create_shortlink_name_authorized( # pylint: disable=too-many-arguments +def test_create_shortlink_name_authorized( shortlinkapp, client, login, user_rincewind, user_wolfgang, create_shortlink ) -> None: """Asking for a custom name will work for site editors.""" @@ -127,7 +123,7 @@ def test_create_shortlink_name_authorized( # pylint: disable=too-many-arguments rv = client.post( create_shortlink, data={ - 'url': user_rincewind.profile.url_for(_external=True), + 'url': user_rincewind.url_for(_external=True), 'name': 'rincewind', }, ) @@ -140,7 +136,7 @@ def test_create_shortlink_name_authorized( # pylint: disable=too-many-arguments rv = client.post( create_shortlink, data={ - 'url': user_rincewind.profile.url_for(_external=True), + 'url': user_rincewind.url_for(_external=True), 'name': 'rincewind', }, ) @@ -153,7 +149,7 @@ def test_create_shortlink_name_authorized( # pylint: disable=too-many-arguments rv = client.post( create_shortlink, data={ - 'url': user_wolfgang.profile.url_for(_external=True), + 'url': user_wolfgang.url_for(_external=True), 'name': 'rincewind', }, ) diff --git a/tests/unit/views/api_support_test.py b/tests/unit/views/api_support_test.py new file mode 100644 index 000000000..c92431e6c --- /dev/null +++ b/tests/unit/views/api_support_test.py @@ -0,0 +1,132 @@ +"""Tests for support API views.""" +# pylint: disable=redefined-outer-name + +from __future__ import annotations + +import secrets + +import pytest +from flask import Flask, url_for +from flask.testing import FlaskClient + +from funnel import models + +VALID_PHONE = '+918123456789' +VALID_PHONE_UNPREFIXED = '8123456789' +VALID_PHONE_ZEROPREFIXED = '08123456789' +VALID_PHONE_INTL = '+12015550123' +VALID_PHONE_INTL_ZEROPREFIXED = '0012015550123' + + +def mock_api_key() -> str: + """Mock API key.""" + return secrets.token_urlsafe() + + +@pytest.fixture() +def user_twoflower_phone(user_twoflower: models.User) -> models.AccountPhone: + """User phone fixture.""" + return user_twoflower.add_phone(VALID_PHONE_INTL) + + +@pytest.fixture() +def user_rincewind_phone(user_rincewind: models.User) -> models.AccountPhone: + """User phone fixture.""" + return user_rincewind.add_phone(VALID_PHONE) + + +@pytest.fixture() +def unaffiliated_phone_number() -> models.PhoneNumber: + """Phone number not affiliated with a user account.""" + return models.PhoneNumber.add(VALID_PHONE) + + +@pytest.mark.mock_config('app', {'INTERNAL_SUPPORT_API_KEY': ...}) +def test_api_key_not_configured(app: Flask, client: FlaskClient) -> None: + """Server must be configured with an API key.""" + app.config.pop('INTERNAL_SUPPORT_API_KEY', None) + rv = client.post(url_for('support_callerid'), data={'number': VALID_PHONE}) + assert rv.status_code == 501 + + +@pytest.mark.mock_config('app', {'INTERNAL_SUPPORT_API_KEY': mock_api_key}) +def test_api_key_mismatch(client: FlaskClient) -> None: + """Client must supply the correct API key.""" + rv = client.post( + url_for('support_callerid'), + data={'number': VALID_PHONE}, + headers={'Authorization': 'Bearer nonsense-key'}, + ) + assert rv.status_code == 403 + + +@pytest.mark.mock_config('app', {'INTERNAL_SUPPORT_API_KEY': mock_api_key}) +def test_valid_phone_unaffiliated( + app: Flask, + client: FlaskClient, + unaffiliated_phone_number: models.PhoneNumber, +) -> None: + """Test phone number not affiliated with a user account.""" + rv = client.post( + url_for('support_callerid'), + data={'number': VALID_PHONE}, + headers={'Authorization': f'Bearer {app.config["INTERNAL_SUPPORT_API_KEY"]}'}, + ) + assert rv.status_code == 200 + data = rv.json + assert isinstance(data, dict) + assert isinstance(data['result'], dict) + assert data['result']['number'] == VALID_PHONE + assert 'account' not in data['result'] + + +@pytest.mark.mock_config('app', {'INTERNAL_SUPPORT_API_KEY': mock_api_key}) +@pytest.mark.parametrize( + 'number', [VALID_PHONE, VALID_PHONE_UNPREFIXED, VALID_PHONE_ZEROPREFIXED] +) +def test_valid_phone_affiliated( + app: Flask, + client: FlaskClient, + user_rincewind_phone: models.AccountPhone, + number: str, +) -> None: + """Test phone number affiliated with a user account.""" + rv = client.post( + url_for('support_callerid'), + data={'number': number}, + headers={'Authorization': f'Bearer {app.config["INTERNAL_SUPPORT_API_KEY"]}'}, + ) + assert rv.status_code == 200 + data = rv.json + assert isinstance(data, dict) + assert isinstance(data['result'], dict) + assert data['result']['number'] == VALID_PHONE + assert data['result']['account'] == { + 'title': user_rincewind_phone.account.fullname, + 'name': user_rincewind_phone.account.username, + } + + +@pytest.mark.mock_config('app', {'INTERNAL_SUPPORT_API_KEY': mock_api_key}) +@pytest.mark.parametrize('number', [VALID_PHONE_INTL, VALID_PHONE_INTL_ZEROPREFIXED]) +def test_valid_phone_intl( + app: Flask, + client: FlaskClient, + user_twoflower_phone: models.AccountPhone, + number: str, +) -> None: + """Test phone number affiliated with a user account.""" + rv = client.post( + url_for('support_callerid'), + data={'number': number}, + headers={'Authorization': f'Bearer {app.config["INTERNAL_SUPPORT_API_KEY"]}'}, + ) + assert rv.status_code == 200 + data = rv.json + assert isinstance(data, dict) + assert isinstance(data['result'], dict) + assert data['result']['number'] == VALID_PHONE_INTL + assert data['result']['account'] == { + 'title': user_twoflower_phone.account.fullname, + 'name': user_twoflower_phone.account.username, + } diff --git a/tests/unit/views/test_helpers.py b/tests/unit/views/helpers_test.py similarity index 97% rename from tests/unit/views/test_helpers.py rename to tests/unit/views/helpers_test.py index ef06081d8..c06d70624 100644 --- a/tests/unit/views/test_helpers.py +++ b/tests/unit/views/helpers_test.py @@ -1,4 +1,5 @@ """Tests for view helpers.""" +# pylint: disable=redefined-outer-name from base64 import urlsafe_b64decode from datetime import datetime, timezone @@ -6,11 +7,10 @@ from unittest.mock import patch from urllib.parse import urlsplit +import pytest from flask import Flask, request -from werkzeug.routing import BuildError - from furl import furl -import pytest +from werkzeug.routing import BuildError import funnel.views.helpers as vhelpers @@ -90,11 +90,15 @@ def test_validate_is_app_url(app) -> None: is False ) assert ( - vhelpers.validate_is_app_url(f'http://{request.host}/profile/project') + vhelpers.validate_is_app_url(f'http://{request.host}/account/project') + is True + ) + assert ( + vhelpers.validate_is_app_url(f'http://{request.host}/account/project/') is True ) assert ( - vhelpers.validate_is_app_url(f'http://{request.host}/profile/project/') + vhelpers.validate_is_app_url(f'http://{request.host}/~account/project/') is True ) diff --git a/tests/unit/views/test_login_session.py b/tests/unit/views/login_session_test.py similarity index 97% rename from tests/unit/views/test_login_session.py rename to tests/unit/views/login_session_test.py index d32bc12b2..26a0bdea9 100644 --- a/tests/unit/views/test_login_session.py +++ b/tests/unit/views/login_session_test.py @@ -1,9 +1,7 @@ """Test login session helpers.""" -# pylint: disable=too-many-arguments - -from flask import session import pytest +from flask import session from funnel.views.login_session import save_session_next_url diff --git a/tests/unit/views/notification_test.py b/tests/unit/views/notification_test.py new file mode 100644 index 000000000..175c9cf1e --- /dev/null +++ b/tests/unit/views/notification_test.py @@ -0,0 +1,222 @@ +"""Test Notification views.""" +# pylint: disable=redefined-outer-name + +from types import SimpleNamespace +from typing import cast +from urllib.parse import urlsplit + +import pytest +from flask import url_for + +from funnel import models +from funnel.transports.sms import SmsTemplate +from funnel.views.notifications.mixins import TemplateVarMixin + + +@pytest.fixture() +def phone_vetinari(db_session, user_vetinari): + """Add a phone number to user_vetinari.""" + accountphone = user_vetinari.add_phone('+12345678900') + db_session.add(accountphone) + db_session.commit() + return accountphone + + +@pytest.fixture() +def notification_prefs_vetinari(db_session, user_vetinari): + """Add main notification preferences for user_vetinari.""" + prefs = models.NotificationPreferences( + notification_type='', + account=user_vetinari, + by_email=True, + by_sms=True, + by_webpush=True, + by_telegram=True, + by_whatsapp=True, + ) + db_session.add(prefs) + db_session.commit() + return prefs + + +@pytest.fixture() +def project_update(db_session, user_vetinari, project_expo2010): + """Create an update to add a notification for.""" + db_session.commit() + update = models.Update( + project=project_expo2010, + created_by=user_vetinari, + title="New update", + body="New update body", + ) + db_session.add(update) + db_session.commit() + update.publish(user_vetinari) + db_session.commit() + return update + + +@pytest.fixture() +def update_notification_recipient(db_session, user_vetinari, project_update): + """Get a user notification for the update fixture.""" + notification = models.NewUpdateNotification(project_update) + db_session.add(notification) + db_session.commit() + + # Extract all the user notifications + all_notification_recipients = list(notification.dispatch()) + db_session.commit() + # There should be only one, assigned to Vetinari, but we'll let the test confirm + assert len(all_notification_recipients) == 1 + return all_notification_recipients[0] + + +def test_notification_recipient_is_user_vetinari( + update_notification_recipient, user_vetinari +) -> None: + """Confirm the test notification is for the test user fixture.""" + assert update_notification_recipient.recipient == user_vetinari + + +@pytest.fixture() +def unsubscribe_sms_short_url( + update_notification_recipient, phone_vetinari, notification_prefs_vetinari +): + """Get an unsubscribe URL for the SMS notification.""" + return update_notification_recipient.views.render.unsubscribe_short_url('sms') + + +def test_unsubscribe_view_is_well_formatted(unsubscribe_sms_short_url) -> None: + """Confirm the SMS unsubscribe URL is well formatted.""" + prefix = 'https://bye.test/' + assert unsubscribe_sms_short_url.startswith(prefix) + assert len(unsubscribe_sms_short_url) == len(prefix) + 4 # 4 char random value + + +def test_unsubscribe_sms_view( + app, client, unsubscribe_sms_short_url, user_vetinari +) -> None: + """Confirm the unsubscribe URL renders a form.""" + unsub_url = url_for( + 'notification_unsubscribe_short', + token=urlsplit(unsubscribe_sms_short_url).path[1:], + _external=True, + ) + + # Get the unsubscribe URL. This should cause a cookie to be set, with a + # redirect to the same URL and `?cookietest=1` appended + rv = client.get(unsub_url) + assert rv.status_code == 302 + assert rv.location.startswith(unsub_url) + assert rv.location.endswith('cookietest=1') + + # Follow the redirect. This will cause yet another redirect + rv = client.get(rv.location) + assert rv.status_code == 302 + # Werkzeug 2.1 defaults to relative URLs in redirects as per the change in RFC 7231: + # https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.2 + # https://github.com/pallets/werkzeug/issues/2352 + # Earlier versions of Werkzeug defaulted to RFC 2616 behaviour for an absolute URL: + # https://datatracker.ietf.org/doc/html/rfc2616#section-14.30 + # This test will fail on Werkzeug < 2.1 + assert rv.location == url_for('notification_unsubscribe_do', _external=False) + + # This time we'll get the unsubscribe form. + rv = client.get(rv.location) + assert rv.status_code == 200 + + # Assert the user has SMS notifications enabled, and the form agrees + assert user_vetinari.main_notification_preferences.by_sms is True + form = rv.form('form-unsubscribe-preferences') + assert form.fields['main'] == 'y' + form.fields['main'] = False + rv = form.submit(client) + # We'll now get an acknowledgement + assert rv.status_code == 200 + # And the user's preferences will be turned off + assert user_vetinari.main_notification_preferences.by_sms is False + + +def test_template_var_mixin() -> None: + """Test TemplateVarMixin for common variables.""" + assert TemplateVarMixin.actor.name != TemplateVarMixin.user.name + t1 = TemplateVarMixin() + t1.var_max_length = 40 + + p1 = SimpleNamespace( + title='Ankh-Morpork 2010', joined_title='Ankh-Morpork / Ankh-Morpork 2010' + ) + u1 = SimpleNamespace( + pickername='Havelock Vetinari (@vetinari)', title='Havelock Vetinari' + ) + u2 = SimpleNamespace(pickername='Twoflower', title='Twoflower') + t1.project = cast(models.Project, p1) + t1.user = cast(models.User, u2) + t1.actor = cast(models.User, u1) + assert isinstance(t1.project, str) + assert isinstance(t1.actor, str) + assert isinstance(t1.user, str) + assert t1.project == 'Ankh-Morpork / Ankh-Morpork 2010' + assert t1.actor == 'Havelock Vetinari (@vetinari)' + assert t1.user == 'Twoflower' + + # Do this again to confirm truncation at a smaller size + t1.var_max_length = 20 + t1.project = cast(models.Project, p1) + t1.user = cast(models.User, u2) + t1.actor = cast(models.User, u1) + assert t1.project == 'Ankh-Morpork 2010' + assert t1.actor == 'Havelock Vetinari' + assert t1.user == 'Twoflower' + + # Again, even smaller + t1.var_max_length = 15 + t1.project = cast(models.Project, p1) + t1.user = cast(models.User, u2) + t1.actor = cast(models.User, u1) + assert t1.project == 'Ankh-Morpork 2…' + assert t1.actor == 'Havelock Vetin…' + assert t1.user == 'Twoflower' + + # Confirm deletion works + del t1.project + with pytest.raises(AttributeError): + t1.project # pylint: disable=pointless-statement + with pytest.raises(AttributeError): + del t1.project + + +class VarMessage(TemplateVarMixin, SmsTemplate): + """Test case for TemplateVarMixin.""" + + registered_template = '{#var#} shared {#var#} with {#var#}: {#var#}' + template = "{actor} shared {project} with {user}: {url}" + plaintext_template = template + + url: str + + +def test_template_var_mixin_in_template( + project_expo2010: models.Project, + user_vetinari: models.User, + user_twoflower: models.User, +) -> None: + """Confirm TemplateVarMixin performs interpolations correctly.""" + assert VarMessage.project is not None + assert VarMessage.project.__set__ is not None + msg = VarMessage( + project=project_expo2010, + actor=user_vetinari, + user=user_twoflower, + url=project_expo2010.url_for(_external=False), + ) + assert msg.project == 'Ankh-Morpork 2010' + assert msg.actor == 'Havelock Vetinari (@vetinari)' + assert msg.user == 'Twoflower' + assert msg.url == '/ankh_morpork/2010/' + assert msg.vars().keys() == {'url'} # Only 'url' was processed by SmsTemplate + assert ( + str(msg) + == 'Havelock Vetinari (@vetinari) shared Ankh-Morpork 2010 with Twoflower:' + ' /ankh_morpork/2010/' + ) diff --git a/tests/unit/views/notifications/conftest.py b/tests/unit/views/notifications/conftest.py index d74b2c3f1..8536d0851 100644 --- a/tests/unit/views/notifications/conftest.py +++ b/tests/unit/views/notifications/conftest.py @@ -10,5 +10,5 @@ def given_vetinari_owner_org(user_vetinari, org_ankhmorpork) -> None: assert 'owner' in org_ankhmorpork.roles_for(user_vetinari) vetinari_admin = org_ankhmorpork.active_owner_memberships[0] - assert vetinari_admin.user == user_vetinari + assert vetinari_admin.member == user_vetinari return vetinari_admin diff --git a/tests/unit/views/notifications/organization_membership_notification.feature b/tests/unit/views/notifications/organization_membership_notification.feature deleted file mode 100644 index ffc42865f..000000000 --- a/tests/unit/views/notifications/organization_membership_notification.feature +++ /dev/null @@ -1,75 +0,0 @@ -Feature: Organization Admin Notification - As an Organization admin, I want to be notified when another admin - is added, removed or has their role changed. - - Background: - Given Vetinari is an owner of the Ankh-Morpork organization - And Vimes is an admin of the Ankh-Morpork organization - - Scenario Outline: Vetinari adds Ridcully - When Vetinari adds Ridcully as - Then gets notified with about the addition - - Examples: - | user | owner_or_admin | notification_string | - | Vetinari | owner | You made Mustrum Ridcully owner of Ankh-Morpork | - | Ridcully | owner | Havelock Vetinari made you owner of Ankh-Morpork | - | Vimes | owner | Havelock Vetinari made Mustrum Ridcully owner of Ankh-Morpork | - | Vetinari | admin | You made Mustrum Ridcully admin of Ankh-Morpork | - | Ridcully | admin | Havelock Vetinari made you admin of Ankh-Morpork | - | Vimes | admin | Havelock Vetinari made Mustrum Ridcully admin of Ankh-Morpork | - - Scenario Outline: Vetinari invites Ridcully - When Vetinari invites Ridcully as - Then gets notified with about the invitation - - Examples: - | user | owner_or_admin | notification_string | - | Vetinari | owner | You invited Mustrum Ridcully to be owner of Ankh-Morpork | - | Ridcully | owner | Havelock Vetinari invited you to be owner of Ankh-Morpork | - | Vimes | owner | Havelock Vetinari invited Mustrum Ridcully to be owner of Ankh-Morpork | - | Vetinari | admin | You invited Mustrum Ridcully to be admin of Ankh-Morpork | - | Ridcully | admin | Havelock Vetinari invited you to be admin of Ankh-Morpork | - | Vimes | admin | Havelock Vetinari invited Mustrum Ridcully to be admin of Ankh-Morpork | - - Scenario Outline: Ridcully accepts the invite - Given Vetinari invites Ridcully as - When Ridcully accepts the invitation to be admin - Then gets notified with about the acceptance - - Examples: - | user | owner_or_admin | notification_string | - | Ridcully | owner | You accepted an invite to be owner of Ankh-Morpork | - | Vetinari | owner | Mustrum Ridcully accepted an invite to be owner of Ankh-Morpork | - | Vimes | owner | Mustrum Ridcully accepted an invite to be owner of Ankh-Morpork | - | Ridcully | admin | You accepted an invite to be admin of Ankh-Morpork | - | Vetinari | admin | Mustrum Ridcully accepted an invite to be admin of Ankh-Morpork | - | Vimes | admin | Mustrum Ridcully accepted an invite to be admin of Ankh-Morpork | - - Scenario Outline: Vetinari changes Ridcully's role - Given Ridcully is currently - When Vetinari changes Ridcully to - Then gets notified with about the change - - Examples: - | user | owner_or_admin | new_role | notification_string | - | Vetinari | owner | admin | You changed Mustrum Ridcully's role to admin of Ankh-Morpork | - | Ridcully | owner | admin | Havelock Vetinari changed your role to admin of Ankh-Morpork | - | Vimes | owner | admin | Havelock Vetinari changed Mustrum Ridcully's role to admin of Ankh-Morpork | - | Vetinari | admin | owner | You changed Mustrum Ridcully's role to owner of Ankh-Morpork | - | Ridcully | admin | owner | Havelock Vetinari changed your role to owner of Ankh-Morpork | - | Vimes | admin | owner | Havelock Vetinari changed Mustrum Ridcully's role to owner of Ankh-Morpork | - - Scenario Outline: Vetinari removes Ridcully - Given Ridcully is currently - When Vetinari removes Ridcully - Then gets notified with about the removal - - Examples: - | user | owner_or_admin | notification_string | - | Vetinari | owner | You removed Mustrum Ridcully from owner of Ankh-Morpork | - | Ridcully | owner | Havelock Vetinari removed you from owner of Ankh-Morpork | - | Vimes | owner | Havelock Vetinari removed Mustrum Ridcully from owner of Ankh-Morpork | - | Vetinari | admin | You removed Mustrum Ridcully from admin of Ankh-Morpork | - | Ridcully | admin | Havelock Vetinari removed you from admin of Ankh-Morpork | - | Vimes | admin | Havelock Vetinari removed Mustrum Ridcully from admin of Ankh-Morpork | diff --git a/tests/unit/views/notifications/test_organization_membership_notification.py b/tests/unit/views/notifications/organization_membership_notification_test.py similarity index 54% rename from tests/unit/views/notifications/test_organization_membership_notification.py rename to tests/unit/views/notifications/organization_membership_notification_test.py index 5ee1bcbb6..b5c8bed55 100644 --- a/tests/unit/views/notifications/test_organization_membership_notification.py +++ b/tests/unit/views/notifications/organization_membership_notification_test.py @@ -1,12 +1,11 @@ """Test template strings in project crew membership notifications.""" -# pylint: disable=too-many-arguments from pytest_bdd import given, parsers, scenarios, then, when from funnel import models from funnel.models.membership_mixin import MEMBERSHIP_RECORD_TYPE -scenarios('organization_membership_notification.feature') +scenarios('notifications/organization_membership_notification.feature') @given( @@ -14,9 +13,9 @@ target_fixture='vimes_admin', ) def given_vimes_admin(db_session, user_vimes, org_ankhmorpork, user_vetinari): - vimes_admin = models.OrganizationMembership( - user=user_vimes, - organization=org_ankhmorpork, + vimes_admin = models.AccountMembership( + member=user_vimes, + account=org_ankhmorpork, granted_by=user_vetinari, is_owner=False, ) @@ -26,11 +25,11 @@ def given_vimes_admin(db_session, user_vimes, org_ankhmorpork, user_vetinari): @when( - parsers.parse("Vetinari adds Ridcully as {owner_or_admin}"), + parsers.parse("Vetinari adds Ridcully as {role}"), target_fixture='ridcully_admin', ) @given( - parsers.parse("Ridcully is currently {owner_or_admin}"), + parsers.parse("Ridcully is currently {role}"), target_fixture='ridcully_admin', ) def when_vetinari_adds_ridcully( @@ -38,12 +37,12 @@ def when_vetinari_adds_ridcully( user_vetinari, user_ridcully, org_ankhmorpork, - owner_or_admin, + role, ): - is_owner = True if owner_or_admin == 'owner' else False - ridcully_admin = models.OrganizationMembership( - user=user_ridcully, - organization=org_ankhmorpork, + is_owner = role == 'owner' + ridcully_admin = models.AccountMembership( + member=user_ridcully, + account=org_ankhmorpork, granted_by=user_vetinari, is_owner=is_owner, ) @@ -53,59 +52,62 @@ def when_vetinari_adds_ridcully( @then( - parsers.parse("{user} gets notified with {notification_string} about the addition") + parsers.parse( + "{recipient} gets notified with a photo of {actor} and message {notification_string} about the addition" + ) +) +@then( + parsers.parse( + "{recipient} gets notified with photo of {actor} and message {notification_string} about the invitation" + ) ) @then( parsers.parse( - "{user} gets notified with {notification_string} about the invitation" + "{recipient} gets notified with photo of {actor} and message {notification_string} about the acceptance" ) ) @then( parsers.parse( - "{user} gets notified with {notification_string} about the acceptance" + "{recipient} gets notified with photo of {actor} and message {notification_string} about the change" ) ) -@then(parsers.parse("{user} gets notified with {notification_string} about the change")) def then_user_gets_notification( - user, notification_string, ridcully_admin, vimes_admin, vetinari_admin + getuser, recipient, actor, notification_string, ridcully_admin ) -> None: - user_dict = { - "Ridcully": ridcully_admin.user, - "Vimes": vimes_admin.user, - "Vetinari": vetinari_admin.user, - } preview = models.PreviewNotification( models.OrganizationAdminMembershipNotification, - document=ridcully_admin.organization, + document=ridcully_admin.account, fragment=ridcully_admin, + user=ridcully_admin.granted_by, ) - user_notification = models.NotificationFor(preview, user_dict[user]) - view = user_notification.views.render + notification_recipient = models.NotificationFor(preview, getuser(recipient)) + view = notification_recipient.views.render + assert view.actor.uuid == getuser(actor).uuid assert ( view.activity_template().format( actor=ridcully_admin.granted_by.fullname, - organization=ridcully_admin.organization.title, - user=ridcully_admin.user.fullname, + organization=ridcully_admin.account.title, + user=ridcully_admin.member.fullname, ) == notification_string ) @given( - parsers.parse("Vetinari invites Ridcully as {owner_or_admin}"), + parsers.parse("Vetinari invites Ridcully as {role}"), target_fixture='ridcully_admin', ) @when( - parsers.parse("Vetinari invites Ridcully as {owner_or_admin}"), + parsers.parse("Vetinari invites Ridcully as {role}"), target_fixture='ridcully_admin', ) def when_vetinari_invites_ridcully( - db_session, user_vetinari, user_ridcully, org_ankhmorpork, owner_or_admin + db_session, user_vetinari, user_ridcully, org_ankhmorpork, role ): - is_owner = True if owner_or_admin == 'owner' else False - ridcully_admin = models.OrganizationMembership( - user=user_ridcully, - organization=org_ankhmorpork, + is_owner = role == 'owner' + ridcully_admin = models.AccountMembership( + member=user_ridcully, + account=org_ankhmorpork, granted_by=user_vetinari, is_owner=is_owner, record_type=MEMBERSHIP_RECORD_TYPE.INVITE, @@ -123,25 +125,25 @@ def when_ridcully_accepts_invite( db_session, ridcully_admin, user_ridcully, -) -> models.ProjectCrewMembership: +) -> models.ProjectMembership: assert ridcully_admin.record_type == MEMBERSHIP_RECORD_TYPE.INVITE - assert ridcully_admin.user == user_ridcully + assert ridcully_admin.member == user_ridcully ridcully_admin_accept = ridcully_admin.accept(actor=user_ridcully) db_session.commit() return ridcully_admin_accept @given( - parsers.parse("Ridcully is currently {owner_or_admin}"), + parsers.parse("Ridcully is currently {role}"), target_fixture='ridcully_admin', ) def given_riduclly_admin( - db_session, user_ridcully, org_ankhmorpork, user_vetinari, owner_or_admin + db_session, user_ridcully, org_ankhmorpork, user_vetinari, role ): - is_owner = True if owner_or_admin == 'owner' else False - ridcully_admin = models.OrganizationMembership( - user=user_ridcully, - organization=org_ankhmorpork, + is_owner = role == 'owner' + ridcully_admin = models.AccountMembership( + member=user_ridcully, + account=org_ankhmorpork, granted_by=user_vetinari, is_owner=is_owner, ) @@ -156,8 +158,8 @@ def given_riduclly_admin( ) def when_vetinari_amends_ridcully_role( db_session, user_vetinari, ridcully_admin, new_role, org_ankhmorpork, user_ridcully -) -> models.ProjectCrewMembership: - is_owner = True if new_role == 'owner' else False +) -> models.ProjectMembership: + is_owner = new_role == 'owner' ridcully_admin_amend = ridcully_admin.replace( actor=user_vetinari, is_owner=is_owner ) @@ -173,39 +175,38 @@ def when_vetinari_removes_ridcully( db_session, user_vetinari, ridcully_admin, -) -> models.ProjectCrewMembership: +) -> models.ProjectMembership: ridcully_admin.revoke(actor=user_vetinari) db_session.commit() return ridcully_admin @then( - parsers.parse("{user} gets notified with {notification_string} about the removal") + parsers.parse( + "{recipient} gets notified with photo of {actor} and message {notification_string} about the removal" + ) ) -def then_user_notification_removal( - user, +def then_notification_recipient_removal( + getuser, + recipient, notification_string, - vimes_admin, + actor, ridcully_admin, - vetinari_admin, ) -> None: - user_dict = { - "Ridcully": ridcully_admin.user, - "Vimes": vimes_admin.user, - "Vetinari": vetinari_admin.user, - } preview = models.PreviewNotification( models.OrganizationAdminMembershipRevokedNotification, - document=ridcully_admin.organization, + document=ridcully_admin.account, fragment=ridcully_admin, + user=ridcully_admin.revoked_by, ) - user_notification = models.NotificationFor(preview, user_dict[user]) - view = user_notification.views.render + notification_recipient = models.NotificationFor(preview, getuser(recipient)) + view = notification_recipient.views.render + assert view.actor.uuid == getuser(actor).uuid assert ( view.activity_template().format( actor=ridcully_admin.granted_by.fullname, - organization=ridcully_admin.organization.title, - user=ridcully_admin.user.fullname, + organization=ridcully_admin.account.title, + user=ridcully_admin.member.fullname, ) == notification_string ) diff --git a/tests/unit/views/notifications/project_crew_notification.feature b/tests/unit/views/notifications/project_crew_notification.feature deleted file mode 100644 index 29002eaa2..000000000 --- a/tests/unit/views/notifications/project_crew_notification.feature +++ /dev/null @@ -1,167 +0,0 @@ -Feature: Project Crew Notification - As a project crew member, I want to be notified of changes to the crew, with a message - telling me exactly what has changed and who did it - - Background: - Given Vetinari is an owner of the Ankh-Morpork organization - And Vetinari is an editor and promoter of the Ankh-Morpork 2010 project - And Vimes is a promoter of the Ankh-Morpork 2010 project - - Scenario Outline: Ridcully is added to a project - When Vetinari adds Ridcully with role to the Ankh-Morpork 2010 project - Then gets notified with about the addition - - Examples: - | user | role | notification_string | - | Vetinari | editor | You made Mustrum Ridcully an editor of Ankh-Morpork 2010 | - | Ridcully | editor | Havelock Vetinari made you an editor of Ankh-Morpork 2010 | - | Vimes | editor | Havelock Vetinari made Mustrum Ridcully an editor of Ankh-Morpork 2010 | - | Vetinari | promoter | You made Mustrum Ridcully a promoter of Ankh-Morpork 2010 | - | Ridcully | promoter | Havelock Vetinari made you a promoter of Ankh-Morpork 2010 | - | Vimes | promoter | Havelock Vetinari made Mustrum Ridcully a promoter of Ankh-Morpork 2010 | - | Vetinari | editor,promoter | You made Mustrum Ridcully an editor and promoter of Ankh-Morpork 2010 | - | Ridcully | editor,promoter | Havelock Vetinari made you an editor and promoter of Ankh-Morpork 2010 | - | Vimes | editor,promoter | Havelock Vetinari made Mustrum Ridcully an editor and promoter of Ankh-Morpork 2010 | - | Vetinari | usher | You added Mustrum Ridcully to the crew of Ankh-Morpork 2010 | - | Ridcully | usher | Havelock Vetinari added you to the crew of Ankh-Morpork 2010 | - | Vimes | usher | Havelock Vetinari added Mustrum Ridcully to the crew of Ankh-Morpork 2010 | - - Scenario Outline: Ridcully adds themself - Given Vetinari made Ridcully an admin of Ankh-Morpork - When Ridcully adds themself with role to the Ankh-Morpork 2010 project - Then gets notified with about the addition - - Examples: - | user | role | notification_string | - | Ridcully | editor | You joined Ankh-Morpork 2010 as editor | - | Vetinari | editor | Mustrum Ridcully joined Ankh-Morpork 2010 as editor | - | Vimes | editor | Mustrum Ridcully joined Ankh-Morpork 2010 as editor | - | Ridcully | promoter | You joined Ankh-Morpork 2010 as promoter | - | Vetinari | promoter | Mustrum Ridcully joined Ankh-Morpork 2010 as promoter | - | Vimes | promoter | Mustrum Ridcully joined Ankh-Morpork 2010 as promoter | - | Ridcully | editor,promoter | You joined Ankh-Morpork 2010 as editor and promoter | - | Vetinari | editor,promoter | Mustrum Ridcully joined Ankh-Morpork 2010 as editor and promoter | - | Vimes | editor,promoter | Mustrum Ridcully joined Ankh-Morpork 2010 as editor and promoter | - | Ridcully | usher | You joined the crew of Ankh-Morpork 2010 | - | Vetinari | usher | Mustrum Ridcully joined the crew of Ankh-Morpork 2010 | - | Vimes | usher | Mustrum Ridcully joined the crew of Ankh-Morpork 2010 | - - Scenario Outline: Vetinari invites Ridcully - When Vetinari invites Ridcully with role to the Ankh-Morpork 2010 project - Then gets notified with about the invitation - - Examples: - | user | role | notification_string | - | Vetinari | editor | You invited Mustrum Ridcully to be an editor of Ankh-Morpork 2010 | - | Ridcully | editor | Havelock Vetinari invited you to be an editor of Ankh-Morpork 2010 | - | Vimes | editor | Havelock Vetinari invited Mustrum Ridcully to be an editor of Ankh-Morpork 2010 | - | Vetinari | promoter | You invited Mustrum Ridcully to be a promoter of Ankh-Morpork 2010 | - | Ridcully | promoter | Havelock Vetinari invited you to be a promoter of Ankh-Morpork 2010 | - | Vimes | promoter | Havelock Vetinari invited Mustrum Ridcully to be a promoter of Ankh-Morpork 2010 | - | Vetinari | editor,promoter | You invited Mustrum Ridcully to be an editor and promoter of Ankh-Morpork 2010 | - | Ridcully | editor,promoter | Havelock Vetinari invited you to be an editor and promoter of Ankh-Morpork 2010 | - | Vimes | editor,promoter | Havelock Vetinari invited Mustrum Ridcully to be an editor and promoter of Ankh-Morpork 2010 | - | Vetinari | usher | You invited Mustrum Ridcully to join the crew of Ankh-Morpork 2010 | - | Ridcully | usher | Havelock Vetinari invited you to join the crew of Ankh-Morpork 2010 | - | Vimes | usher | Havelock Vetinari invited Mustrum Ridcully to join the crew of Ankh-Morpork 2010 | - - Scenario Outline: Ridcully accepted the invite - Given Vetinari invited Ridcully with role to the Ankh-Morpork 2010 project - When Ridcully accepts the invitation to be a crew member of the Ankh-Morpork 2010 project - Then gets notified with about the acceptance - - Examples: - | user | role | notification_string | - | Ridcully | editor | You accepted an invite to be editor of Ankh-Morpork 2010 | - | Vetinari | editor | Mustrum Ridcully accepted an invite to be editor of Ankh-Morpork 2010 | - | Vimes | editor | Mustrum Ridcully accepted an invite to be editor of Ankh-Morpork 2010 | - | Ridcully | promoter | You accepted an invite to be promoter of Ankh-Morpork 2010 | - | Vetinari | promoter | Mustrum Ridcully accepted an invite to be promoter of Ankh-Morpork 2010 | - | Vimes | promoter | Mustrum Ridcully accepted an invite to be promoter of Ankh-Morpork 2010 | - | Ridcully | editor,promoter | You accepted an invite to be editor and promoter of Ankh-Morpork 2010 | - | Vetinari | editor,promoter | Mustrum Ridcully accepted an invite to be editor and promoter of Ankh-Morpork 2010 | - | Vimes | editor,promoter | Mustrum Ridcully accepted an invite to be editor and promoter of Ankh-Morpork 2010 | - | Ridcully | usher | You accepted an invite to join the crew of Ankh-Morpork 2010 | - | Vetinari | usher | Mustrum Ridcully accepted an invite to join the crew of Ankh-Morpork 2010 | - | Vimes | usher | Mustrum Ridcully accepted an invite to join the crew of Ankh-Morpork 2010 | - - Scenario Outline: Vetinari changes Ridcully's role - Given Ridcully is an existing crew member with roles editor, promoter and usher of the Ankh-Morpork 2010 project - When Vetinari changes Ridcully's role to in the Ankh-Morpork 2010 project - Then gets notified with about the change - - Examples: - | user | role | notification_string | - | Vetinari | editor | You changed Mustrum Ridcully's role to editor of Ankh-Morpork 2010 | - | Ridcully | editor | Havelock Vetinari changed your role to editor of Ankh-Morpork 2010 | - | Vimes | editor | Havelock Vetinari changed Mustrum Ridcully's role to editor of Ankh-Morpork 2010 | - | Vetinari | promoter | You changed Mustrum Ridcully's role to promoter of Ankh-Morpork 2010 | - | Ridcully | promoter | Havelock Vetinari changed your role to promoter of Ankh-Morpork 2010 | - | Vimes | promoter | Havelock Vetinari changed Mustrum Ridcully's role to promoter of Ankh-Morpork 2010 | - | Vetinari | editor,promoter | You changed Mustrum Ridcully's role to editor and promoter of Ankh-Morpork 2010 | - | Ridcully | editor,promoter | Havelock Vetinari changed your role to editor and promoter of Ankh-Morpork 2010 | - | Vimes | editor,promoter | Havelock Vetinari changed Mustrum Ridcully's role to editor and promoter of Ankh-Morpork 2010 | - | Vetinari | usher | You changed Mustrum Ridcully's role to crew member of Ankh-Morpork 2010 | - | Ridcully | usher | Havelock Vetinari changed your role to crew member of Ankh-Morpork 2010 | - | Vimes | usher | Havelock Vetinari changed Mustrum Ridcully's role to crew member of Ankh-Morpork 2010 | - - Scenario Outline: Ridcully changed their own role - Given Vetinari made Ridcully an admin of Ankh-Morpork - And Ridcully is an existing crew member with roles editor, promoter and usher of the Ankh-Morpork 2010 project - When Ridcully changes their role to in the Ankh-Morpork 2010 project - Then gets notified with about the change - - Examples: - | user | role | notification_string | - | Ridcully | editor | You changed your role to editor of Ankh-Morpork 2010 | - | Vimes | editor | Mustrum Ridcully changed their role to editor of Ankh-Morpork 2010 | - | Vetinari | editor | Mustrum Ridcully changed their role to editor of Ankh-Morpork 2010 | - | Ridcully | promoter | You changed your role to promoter of Ankh-Morpork 2010 | - | Vimes | promoter | Mustrum Ridcully changed their role to promoter of Ankh-Morpork 2010 | - | Vetinari | promoter | Mustrum Ridcully changed their role to promoter of Ankh-Morpork 2010 | - | Ridcully | editor,promoter | You are now editor and promoter of Ankh-Morpork 2010 | - | Vimes | editor,promoter | Mustrum Ridcully changed their role to editor and promoter of Ankh-Morpork 2010 | - | Vetinari | editor,promoter | Mustrum Ridcully changed their role to editor and promoter of Ankh-Morpork 2010 | - | Ridcully | usher | You changed your role to crew member of Ankh-Morpork 2010 | - | Vimes | usher | Mustrum Ridcully changed their role to crew member of Ankh-Morpork 2010 | - | Vetinari | usher | Mustrum Ridcully changed their role to crew member of Ankh-Morpork 2010 | - - Scenario Outline: Vetinari removes Ridcully - Given Ridcully is an existing crew member of the Ankh-Morpork 2010 project with role - When Vetinari removes Ridcully from the Ankh-Morpork 2010 project crew - Then is notified of the removal with - - Examples: - | user | role | notification_string | - | Vetinari | editor | You removed Mustrum Ridcully from editor of Ankh-Morpork 2010 | - | Ridcully | editor | Havelock Vetinari removed you from editor of Ankh-Morpork 2010 | - | Vimes | editor | Havelock Vetinari removed Mustrum Ridcully from editor of Ankh-Morpork 2010 | - | Vetinari | promoter | You removed Mustrum Ridcully from promoter of Ankh-Morpork 2010 | - | Ridcully | promoter | Havelock Vetinari removed you from promoter of Ankh-Morpork 2010 | - | Vimes | promoter | Havelock Vetinari removed Mustrum Ridcully from promoter of Ankh-Morpork 2010 | - | Vetinari | editor,promoter | You removed Mustrum Ridcully from editor and promoter of Ankh-Morpork 2010 | - | Ridcully | editor,promoter | Havelock Vetinari removed you from editor and promoter of Ankh-Morpork 2010 | - | Vimes | editor,promoter | Havelock Vetinari removed Mustrum Ridcully from editor and promoter of Ankh-Morpork 2010 | - | Vetinari | usher | You removed Mustrum Ridcully from the crew of Ankh-Morpork 2010 | - | Ridcully | usher | Havelock Vetinari removed you from the crew of Ankh-Morpork 2010 | - | Vimes | usher | Havelock Vetinari removed Mustrum Ridcully from the crew of Ankh-Morpork 2010 | - - Scenario Outline: Ridcully resigns - Given Ridcully is an existing crew member of the Ankh-Morpork 2010 project with role - When Ridcully resigns from the Ankh-Morpork 2010 project crew - Then is notified of the removal with - - Examples: - | user | role | notification_string | - | Ridcully | editor | You resigned as editor of Ankh-Morpork 2010 | - | Vetinari | editor | Mustrum Ridcully resigned as editor of Ankh-Morpork 2010 | - | Vimes | editor | Mustrum Ridcully resigned as editor of Ankh-Morpork 2010 | - | Ridcully | promoter | You resigned as promoter of Ankh-Morpork 2010 | - | Vetinari | promoter | Mustrum Ridcully resigned as promoter of Ankh-Morpork 2010 | - | Vimes | promoter | Mustrum Ridcully resigned as promoter of Ankh-Morpork 2010 | - | Ridcully | editor,promoter | You resigned as editor and promoter of Ankh-Morpork 2010 | - | Vetinari | editor,promoter | Mustrum Ridcully resigned as editor and promoter of Ankh-Morpork 2010 | - | Vimes | editor,promoter | Mustrum Ridcully resigned as editor and promoter of Ankh-Morpork 2010 | - | Ridcully | usher | You resigned from the crew of Ankh-Morpork 2010 | - | Vetinari | usher | Mustrum Ridcully resigned from the crew of Ankh-Morpork 2010 | - | Vimes | usher | Mustrum Ridcully resigned from the crew of Ankh-Morpork 2010 | diff --git a/tests/unit/views/notifications/test_project_crew_notification.py b/tests/unit/views/notifications/project_crew_notification_test.py similarity index 74% rename from tests/unit/views/notifications/test_project_crew_notification.py rename to tests/unit/views/notifications/project_crew_notification_test.py index 04d5d932e..299c8a248 100644 --- a/tests/unit/views/notifications/test_project_crew_notification.py +++ b/tests/unit/views/notifications/project_crew_notification_test.py @@ -1,12 +1,11 @@ """Test template strings in project crew membership notifications.""" -# pylint: disable=too-many-arguments from pytest_bdd import given, parsers, scenarios, then, when from funnel import models from funnel.models.membership_mixin import MEMBERSHIP_RECORD_TYPE -scenarios('project_crew_notification.feature') +scenarios('notifications/project_crew_notification.feature') def role_columns(role): @@ -24,11 +23,11 @@ def role_columns(role): def given_vetinari_editor_promoter_project( user_vetinari, project_expo2010, -) -> models.ProjectCrewMembership: +) -> models.ProjectMembership: assert 'promoter' in project_expo2010.roles_for(user_vetinari) assert 'editor' in project_expo2010.roles_for(user_vetinari) vetinari_member = project_expo2010.crew_memberships[0] - assert vetinari_member.user == user_vetinari + assert vetinari_member.member == user_vetinari return vetinari_member @@ -41,10 +40,10 @@ def given_vimes_promoter_project( user_vetinari, user_vimes, project_expo2010, -) -> models.ProjectCrewMembership: - vimes_member = models.ProjectCrewMembership( +) -> models.ProjectMembership: + vimes_member = models.ProjectMembership( parent=project_expo2010, - user=user_vimes, + member=user_vimes, is_promoter=True, granted_by=user_vetinari, ) @@ -66,10 +65,10 @@ def when_vetinari_adds_ridcully( user_ridcully, project_expo2010, user_vetinari, -) -> models.ProjectCrewMembership: - ridcully_member = models.ProjectCrewMembership( +) -> models.ProjectMembership: + ridcully_member = models.ProjectMembership( parent=project_expo2010, - user=user_ridcully, + member=user_ridcully, granted_by=user_vetinari, **role_columns(role), ) @@ -80,43 +79,41 @@ def when_vetinari_adds_ridcully( @then( parsers.parse( - "{user} gets notified with {notification_string} about the invitation" + "{recipient} gets notified with photo of {actor} and message {notification_string} about the invitation" ) ) @then( - parsers.parse("{user} gets notified with {notification_string} about the addition") + parsers.parse( + "{recipient} gets notified with photo of {actor} and message {notification_string} about the addition" + ) +) +@then( + parsers.parse( + "{recipient} gets notified with photo of {actor} and message {notification_string} about the acceptance" + ) ) @then( parsers.parse( - "{user} gets notified with {notification_string} about the acceptance" + "{recipient} gets notified with photo of {actor} and message {notification_string} about the change" ) ) -@then(parsers.parse("{user} gets notified with {notification_string} about the change")) def then_user_gets_notification( - user, - notification_string, - user_vimes, - user_ridcully, - user_vetinari, - ridcully_member, + getuser, recipient, notification_string, actor, ridcully_member ) -> None: - user_dict = { - "Ridcully": user_ridcully, - "Vimes": user_vimes, - "Vetinari": user_vetinari, - } preview = models.PreviewNotification( models.ProjectCrewMembershipNotification, document=ridcully_member.project, fragment=ridcully_member, + user=ridcully_member.granted_by, ) - user_notification = models.NotificationFor(preview, user_dict[user]) - view = user_notification.views.render + notification_recipient = models.NotificationFor(preview, getuser(recipient)) + view = notification_recipient.views.render + assert view.actor.uuid == getuser(actor).uuid assert ( view.activity_template().format( actor=ridcully_member.granted_by.fullname, project=ridcully_member.project.joined_title, - user=ridcully_member.user.fullname, + user=ridcully_member.member.fullname, ) == notification_string ) @@ -140,10 +137,10 @@ def when_vetinari_invites_ridcully( user_ridcully, project_expo2010, user_vetinari, -) -> models.ProjectCrewMembership: - ridcully_member = models.ProjectCrewMembership( +) -> models.ProjectMembership: + ridcully_member = models.ProjectMembership( parent=project_expo2010, - user=user_ridcully, + member=user_ridcully, granted_by=user_vetinari, record_type=MEMBERSHIP_RECORD_TYPE.INVITE, **role_columns(role), @@ -162,9 +159,9 @@ def when_ridcully_accepts_invite( db_session, ridcully_member, user_ridcully, -) -> models.ProjectCrewMembership: +) -> models.ProjectMembership: assert ridcully_member.record_type == MEMBERSHIP_RECORD_TYPE.INVITE - assert ridcully_member.user == user_ridcully + assert ridcully_member.member == user_ridcully ridcully_member_accept = ridcully_member.accept(actor=user_ridcully) db_session.commit() return ridcully_member_accept @@ -180,10 +177,10 @@ def given_ridcully_is_existing_crew( user_vetinari, user_ridcully, project_expo2010, -) -> models.ProjectCrewMembership: - ridcully_member = models.ProjectCrewMembership( +) -> models.ProjectMembership: + ridcully_member = models.ProjectMembership( parent=project_expo2010, - user=user_ridcully, + member=user_ridcully, is_usher=True, is_promoter=True, is_editor=True, @@ -205,7 +202,7 @@ def when_vetinari_amends_ridcully_role( db_session, user_vetinari, ridcully_member, -) -> models.ProjectCrewMembership: +) -> models.ProjectMembership: ridcully_member_amend = ridcully_member.replace( actor=user_vetinari, **role_columns(role), @@ -225,7 +222,7 @@ def when_ridcully_changes_role( db_session, user_ridcully, ridcully_member, -) -> models.ProjectCrewMembership: +) -> models.ProjectMembership: ridcully_member_amend = ridcully_member.replace( actor=user_ridcully, **role_columns(role), @@ -242,9 +239,9 @@ def given_vetinari_made_ridcully_admin_of_org( user_ridcully, org_ankhmorpork, user_vetinari, -) -> models.OrganizationMembership: - ridcully_admin = models.OrganizationMembership( - user=user_ridcully, organization=org_ankhmorpork, granted_by=user_vetinari +) -> models.AccountMembership: + ridcully_admin = models.AccountMembership( + member=user_ridcully, account=org_ankhmorpork, granted_by=user_vetinari ) db_session.add(ridcully_admin) db_session.commit() @@ -264,10 +261,10 @@ def given_ridcully_is_existing_member( user_ridcully, project_expo2010, user_vetinari, -) -> models.ProjectCrewMembership: - existing_ridcully_member = models.ProjectCrewMembership( +) -> models.ProjectMembership: + existing_ridcully_member = models.ProjectMembership( parent=project_expo2010, - user=user_ridcully, + member=user_ridcully, granted_by=user_vetinari, **role_columns(role), ) @@ -284,7 +281,7 @@ def when_vetinari_removes_ridcully( db_session, user_vetinari, ridcully_member, -) -> models.ProjectCrewMembership: +) -> models.ProjectMembership: ridcully_member.revoke(actor=user_vetinari) db_session.commit() return ridcully_member @@ -298,36 +295,37 @@ def when_ridcully_resigns( db_session, user_ridcully, ridcully_member, -) -> models.ProjectCrewMembership: +) -> models.ProjectMembership: ridcully_member.revoke(user_ridcully) db_session.commit() return ridcully_member -@then(parsers.parse("{user} is notified of the removal with {notification_string}")) -def then_user_notification_removal( - user, +@then( + parsers.parse( + "{recipient} is notified of the removal with photo of {actor} and message {notification_string}" + ) +) +def then_notification_recipient_removal( + getuser, + recipient, notification_string, ridcully_member, - vetinari_member, - vimes_member, + actor, ) -> None: - user_dict = { - "Ridcully": ridcully_member.user, - "Vimes": vimes_member.user, - "Vetinari": vetinari_member.user, - } preview = models.PreviewNotification( models.ProjectCrewMembershipRevokedNotification, document=ridcully_member.project, fragment=ridcully_member, + user=ridcully_member.revoked_by, ) - user_notification = models.NotificationFor(preview, user_dict[user]) - view = user_notification.views.render + notification_recipient = models.NotificationFor(preview, getuser(recipient)) + view = notification_recipient.views.render + assert view.actor.uuid == getuser(actor).uuid assert ( view.activity_template().format( project=ridcully_member.project.joined_title, - user=ridcully_member.user.fullname, + user=ridcully_member.member.fullname, actor=ridcully_member.revoked_by.fullname, ) == notification_string @@ -346,10 +344,10 @@ def when_ridcully_adds_themself( user_ridcully, project_expo2010, user_vetinari, -) -> models.ProjectCrewMembership: - ridcully_member = models.ProjectCrewMembership( +) -> models.ProjectMembership: + ridcully_member = models.ProjectMembership( parent=project_expo2010, - user=user_ridcully, + member=user_ridcully, granted_by=user_ridcully, **role_columns(role), ) diff --git a/tests/unit/views/test_project_spa.py b/tests/unit/views/project_spa_test.py similarity index 87% rename from tests/unit/views/test_project_spa.py rename to tests/unit/views/project_spa_test.py index ab7b606bd..6ebc7dc56 100644 --- a/tests/unit/views/test_project_spa.py +++ b/tests/unit/views/project_spa_test.py @@ -1,6 +1,6 @@ """Test response types for project SPA endpoints.""" +# pylint: disable=redefined-outer-name -from typing import Optional from urllib.parse import urlsplit import pytest @@ -34,19 +34,19 @@ def test_project_url_is_as_expected(project_url) -> None: # URL ends with '/' assert project_url.endswith('/') # URL is relative (for tests) - assert project_url == '/ankh-morpork/2010/' + assert project_url == '/ankh_morpork/2010/' @pytest.mark.parametrize('page', subpages) @pytest.mark.parametrize('xhr', xhr_headers) @pytest.mark.parametrize('use_login', login_sessions) -def test_default_is_html( # pylint: disable=too-many-arguments +def test_default_is_html( request, client, - use_login: Optional[str], + use_login: str | None, project_url: str, page: str, - xhr: Optional[dict], + xhr: dict | None, ) -> None: """Pages render as full HTML by default.""" if use_login: @@ -63,13 +63,13 @@ def test_default_is_html( # pylint: disable=too-many-arguments @pytest.mark.parametrize('page', subpages) @pytest.mark.parametrize('xhr', xhr_headers) @pytest.mark.parametrize('use_login', login_sessions) -def test_html_response( # pylint: disable=too-many-arguments +def test_html_response( request, client, - use_login: Optional[str], + use_login: str | None, project_url: str, page: str, - xhr: Optional[dict], + xhr: dict | None, ) -> None: """Asking for a HTML page or a fragment (via XHR) returns a page or a fragment.""" if use_login: @@ -86,7 +86,7 @@ def test_html_response( # pylint: disable=too-many-arguments @pytest.mark.parametrize('page', subpages) @pytest.mark.parametrize('use_login', login_sessions) def test_json_response( - request, client, use_login: Optional[str], project_url: str, page: str + request, client, use_login: str | None, project_url: str, page: str ) -> None: """Asking for JSON returns a JSON response.""" if use_login: @@ -102,13 +102,13 @@ def test_json_response( @pytest.mark.parametrize('page', subpages) @pytest.mark.parametrize('xhr', xhr_headers) @pytest.mark.parametrize('use_login', login_sessions) -def test_htmljson_response( # pylint: disable=too-many-arguments +def test_htmljson_response( request, client, - use_login: Optional[str], + use_login: str | None, project_url: str, page: str, - xhr: Optional[dict], + xhr: dict | None, ) -> None: """Asking for HTML in JSON returns that as a HTML fragment.""" if use_login: diff --git a/tests/unit/views/test_project_sponsorship.py b/tests/unit/views/project_sponsorship_test.py similarity index 80% rename from tests/unit/views/test_project_sponsorship.py rename to tests/unit/views/project_sponsorship_test.py index 619da6dd1..442485e36 100644 --- a/tests/unit/views/test_project_sponsorship.py +++ b/tests/unit/views/project_sponsorship_test.py @@ -1,5 +1,5 @@ """Test ProjectSponsorship views.""" -# pylint: disable=too-many-arguments +# pylint: disable=redefined-outer-name import pytest @@ -10,7 +10,7 @@ def org_uu_sponsorship(db_session, user_vetinari, org_uu, project_expo2010): sponsorship = models.ProjectSponsorMembership( granted_by=user_vetinari, - profile=org_uu.profile, + member=org_uu, project=project_expo2010, is_promoted=True, label="Diamond", @@ -23,7 +23,7 @@ def org_uu_sponsorship(db_session, user_vetinari, org_uu, project_expo2010): @pytest.fixture() def user_vetinari_site_editor(db_session, user_vetinari): site_editor = models.SiteMembership( - user=user_vetinari, granted_by=user_vetinari, is_site_editor=True + member=user_vetinari, granted_by=user_vetinari, is_site_editor=True ) db_session.add(site_editor) db_session.commit() @@ -33,7 +33,7 @@ def user_vetinari_site_editor(db_session, user_vetinari): @pytest.fixture() def user_twoflower_not_site_editor(db_session, user_twoflower): not_site_editor = models.SiteMembership( - user=user_twoflower, granted_by=user_twoflower, is_comment_moderator=True + member=user_twoflower, granted_by=user_twoflower, is_comment_moderator=True ) db_session.add(not_site_editor) db_session.commit() @@ -44,10 +44,10 @@ def user_twoflower_not_site_editor(db_session, user_twoflower): ('user_site_membership', 'status_code'), [('user_vetinari_site_editor', 200), ('user_twoflower_not_site_editor', 403)], ) -def test_check_site_editor_edit_sponsorship( # pylint: disable=too-many-arguments +def test_check_site_editor_edit_sponsorship( request, app, client, login, org_uu_sponsorship, user_site_membership, status_code ) -> None: - login.as_(request.getfixturevalue(user_site_membership).user) + login.as_(request.getfixturevalue(user_site_membership).member) endpoint = org_uu_sponsorship.url_for('edit') rv = client.get(endpoint) assert rv.status_code == status_code @@ -62,7 +62,7 @@ def test_check_site_editor_edit_sponsorship( # pylint: disable=too-many-argumen ('Test sponsor2', True), ], ) -def test_sponsorship_add( # pylint: disable=too-many-arguments +def test_sponsorship_add( app, client, login, @@ -73,10 +73,10 @@ def test_sponsorship_add( # pylint: disable=too-many-arguments is_promoted, csrf_token, ) -> None: - login.as_(user_vetinari_site_editor.user) + login.as_(user_vetinari_site_editor.member) endpoint = project_expo2010.url_for('add_sponsor') data = { - 'profile': org_uu.name, + 'member': org_uu.name, 'label': label, 'csrf_token': csrf_token, } @@ -90,10 +90,10 @@ def test_sponsorship_add( # pylint: disable=too-many-arguments added_sponsorship = models.ProjectSponsorMembership.query.filter( models.ProjectSponsorMembership.is_active, models.ProjectSponsorMembership.project == project_expo2010, - models.ProjectSponsorMembership.profile == org_uu.profile, + models.ProjectSponsorMembership.member == org_uu, ).one_or_none() assert added_sponsorship is not None - assert added_sponsorship.profile == org_uu.profile + assert added_sponsorship.member == org_uu assert added_sponsorship.label == label assert added_sponsorship.is_promoted is is_promoted @@ -107,7 +107,7 @@ def test_sponsorship_edit( csrf_token, ) -> None: assert org_uu_sponsorship.is_promoted is True - login.as_(user_vetinari_site_editor.user) + login.as_(user_vetinari_site_editor.member) endpoint = org_uu_sponsorship.url_for('edit') data = { 'label': "Edited", @@ -120,13 +120,13 @@ def test_sponsorship_edit( edited_sponsorship = models.ProjectSponsorMembership.query.filter( models.ProjectSponsorMembership.is_active, models.ProjectSponsorMembership.project == org_uu_sponsorship.project, - models.ProjectSponsorMembership.profile == org_uu_sponsorship.profile, + models.ProjectSponsorMembership.member == org_uu_sponsorship.member, ).one_or_none() assert edited_sponsorship.label == "Edited" assert edited_sponsorship.is_promoted is False -def test_sponsorship_remove( # pylint: disable=too-many-arguments +def test_sponsorship_remove( db_session, app, client, @@ -149,7 +149,7 @@ def test_sponsorship_remove( # pylint: disable=too-many-arguments no_sponsor = models.ProjectSponsorMembership.query.filter( models.ProjectSponsorMembership.is_active, models.ProjectSponsorMembership.project == org_uu_sponsorship.project, - models.ProjectSponsorMembership.profile == org_uu_sponsorship.profile, + models.ProjectSponsorMembership.member == org_uu_sponsorship.member, ).one_or_none() assert no_sponsor is None assert org_uu_sponsorship.is_active is False diff --git a/tests/unit/views/test_project.py b/tests/unit/views/project_test.py similarity index 78% rename from tests/unit/views/test_project.py rename to tests/unit/views/project_test.py index 0d1d8d357..241cf1e45 100644 --- a/tests/unit/views/test_project.py +++ b/tests/unit/views/project_test.py @@ -3,13 +3,15 @@ from funnel.views.project import get_registration_text -def test_registration_text() -> None: +def test_registration_text(app_context) -> None: assert get_registration_text(count=0, registered=False).startswith("Be the first") - assert get_registration_text(count=1, registered=True).startswith("You have") + assert get_registration_text(count=1, registered=True).startswith( + "You have registered" + ) assert get_registration_text(count=1, registered=False).startswith("One") - assert get_registration_text(count=2, registered=True).startswith("You and one") - assert get_registration_text(count=5, registered=True).startswith("You and four") + assert get_registration_text(count=2, registered=True).startswith("You & one") + assert get_registration_text(count=5, registered=True).startswith("You & four") assert get_registration_text(count=5, registered=False).startswith("Five") # More than ten - assert get_registration_text(count=33, registered=True).startswith("You and 32") - assert get_registration_text(count=3209, registered=False).startswith("3209") + assert get_registration_text(count=33, registered=True).startswith("You & 32") + assert get_registration_text(count=3209, registered=False).startswith("3,209") diff --git a/tests/unit/views/rsvp_test.py b/tests/unit/views/rsvp_test.py new file mode 100644 index 000000000..a4ccdf58b --- /dev/null +++ b/tests/unit/views/rsvp_test.py @@ -0,0 +1,190 @@ +"""Test custom rsvp form views.""" +# pylint: disable=redefined-outer-name + +import datetime + +import pytest +from werkzeug.datastructures import MultiDict + +from funnel import models + +valid_schema = { + 'fields': [ + { + 'description': "An explanation for this field", + 'name': 'field_name', + 'title': "Field label shown to user", + 'type': 'string', + }, + { + 'name': 'has_checked', + 'title': "I accept the terms", + 'type': 'boolean', + }, + { + 'name': 'choice', + 'title': "Choose one", + 'choices': ["First choice", "Second choice", "Third choice"], + }, + ] +} + + +valid_json_rsvp = { + 'field_name': 'Twoflower', + 'has_checked': 'on', + 'choice': 'First choice', +} + +rsvp_excess_json = { + 'choice': 'First choice', + 'field_name': 'Twoflower', + 'has_checked': 'on', + 'company': 'MAANG', # This is extra +} + + +@pytest.fixture() +def project_expo2010(project_expo2010: models.Project) -> models.Project: + """Project fixture with a registration form.""" + project_expo2010.start_at = datetime.datetime.now() + datetime.timedelta(days=1) + project_expo2010.end_at = datetime.datetime.now() + datetime.timedelta(days=2) + project_expo2010.boxoffice_data = { + "org": "", + "is_subscription": False, + "item_collection_id": "", + "register_button_txt": "Follow", + "register_form_schema": { + "fields": [ + { + "name": "field_name", + "type": "string", + "title": "Field label shown to user", + }, + { + "name": "has_checked", + "type": "boolean", + "title": "I accept the terms", + }, + { + "name": "choice", + "type": "select", + "title": "Choose one", + "choices": ["First choice", "Second choice", "Third choice"], + }, + ] + }, + } + return project_expo2010 + + +# Organizer side testing +def test_valid_registration_form_schema( + app, + client, + login, + csrf_token: str, + user_vetinari: models.User, + project_expo2010: models.Project, +) -> None: + """A project can have a registration form provided it is valid JSON.""" + login.as_(user_vetinari) + endpoint = project_expo2010.url_for('edit_boxoffice_data') + rv = client.post( + endpoint, + data=MultiDict( + { + 'org': '', + 'item_collection_id': '', + 'allow_rsvp': True, + 'is_subscription': False, + 'register_button_txt': 'Follow', + 'register_form_schema': app.json.dumps(valid_schema), + 'csrf_token': csrf_token, + } + ), + ) + assert rv.status_code == 303 + + +def test_invalid_registration_form_schema( + client, + login, + csrf_token: str, + user_vetinari: models.User, + project_expo2010: models.Project, +) -> None: + """Registration form schema must be JSON or will be rejected.""" + login.as_(user_vetinari) + endpoint = project_expo2010.url_for('edit_boxoffice_data') + rv = client.post( + endpoint, + data={ + 'register_form_schema': 'This is invalid JSON', + 'csrf_token': csrf_token, + }, + ) + # Confirm no redirect on success + assert not 300 <= rv.status_code < 400 + assert 'Invalid JSON' in rv.data.decode() + + +def test_valid_json_register( + app, + client, + login, + csrf_token: str, + user_twoflower: models.User, + project_expo2010: models.Project, +) -> None: + """A user can register when the submitted form matches the form schema.""" + login.as_(user_twoflower) + endpoint = project_expo2010.url_for('register') + rv = client.post( + endpoint, + data=app.json.dumps( + { + 'form': valid_json_rsvp, + 'csrf_token': csrf_token, + } + ), + headers={'Content-Type': 'application/json'}, + ) + assert rv.status_code == 303 + assert project_expo2010.rsvp_for(user_twoflower).form == valid_json_rsvp + + +def test_valid_encoded_json_register( + app, + client, + login, + csrf_token: str, + user_twoflower: models.User, + project_expo2010: models.Project, +) -> None: + """A form submission can use non-JSON POST provided the form itself is JSON.""" + login.as_(user_twoflower) + endpoint = project_expo2010.url_for('register') + rv = client.post( + endpoint, + data={ + 'form': app.json.dumps(valid_json_rsvp), + 'csrf_token': csrf_token, + }, + ) + assert rv.status_code == 303 + assert project_expo2010.rsvp_for(user_twoflower).form == valid_json_rsvp + + +def test_invalid_json_register( + client, login, user_twoflower: models.User, project_expo2010: models.Project +) -> None: + """If a registration form is not JSON, it is rejected.""" + login.as_(user_twoflower) + endpoint = project_expo2010.url_for('register') + rv = client.post( + endpoint, + data="This is not JSON", + headers={'Content-Type': 'application/json'}, + ) + assert rv.status_code == 400 diff --git a/tests/unit/views/test_search.py b/tests/unit/views/search_test.py similarity index 80% rename from tests/unit/views/test_search.py rename to tests/unit/views/search_test.py index af9529382..3e012acce 100644 --- a/tests/unit/views/test_search.py +++ b/tests/unit/views/search_test.py @@ -5,44 +5,57 @@ views are returning expected results (at this time). Proper search testing requires a corpus of searchable data in fixtures. """ +# pylint: disable=redefined-outer-name from typing import cast -from flask import url_for - import pytest +from flask import url_for +from funnel.models import Query from funnel.views.search import ( - Query, - SearchInProfileProvider, + SearchInAccountProvider, SearchInProjectProvider, + get_tsquery, search_counts, search_providers, ) search_all_types = list(search_providers.keys()) search_profile_types = [ - k for k, v in search_providers.items() if isinstance(v, SearchInProfileProvider) + k for k, v in search_providers.items() if isinstance(v, SearchInAccountProvider) ] search_project_types = [ k for k, v in search_providers.items() if isinstance(v, SearchInProjectProvider) ] + +@pytest.fixture() +def db_session(db_session_truncate): + """ + Use the database session truncate fixture. + + The default rollback fixture is not compatible with Flask-Executor, which is used + in the search methods to parallelize tasks. + """ + return db_session_truncate + + # --- Tests for datatypes returned by search providers --------------------------------- @pytest.mark.parametrize('stype', search_all_types) def test_search_all_count_returns_int(stype, all_fixtures) -> None: """Assert that all_count() returns an int.""" - assert isinstance(search_providers[stype].all_count("test"), int) + assert isinstance(search_providers[stype].all_count(get_tsquery("test")), int) @pytest.mark.parametrize('stype', search_profile_types) def test_search_profile_count_returns_int(stype, org_ankhmorpork, all_fixtures) -> None: """Assert that profile_count() returns an int.""" assert isinstance( - cast(SearchInProfileProvider, search_providers[stype]).profile_count( - "test", org_ankhmorpork.profile + cast(SearchInAccountProvider, search_providers[stype]).account_count( + get_tsquery("test"), org_ankhmorpork ), int, ) @@ -54,8 +67,8 @@ def test_search_project_count_returns_int( ) -> None: """Assert that project_count() returns an int.""" assert isinstance( - cast(SearchInProjectProvider, search_providers[stype]).profile_count( - "test", project_expo2010 + cast(SearchInProjectProvider, search_providers[stype]).project_count( + get_tsquery("test"), project_expo2010 ), int, ) @@ -64,15 +77,15 @@ def test_search_project_count_returns_int( @pytest.mark.parametrize('stype', search_all_types) def test_search_all_returns_query(stype, all_fixtures) -> None: """Assert that all_query() returns a query.""" - assert isinstance(search_providers[stype].all_query("test"), Query) + assert isinstance(search_providers[stype].all_query(get_tsquery("test")), Query) @pytest.mark.parametrize('stype', search_profile_types) def test_search_profile_returns_query(stype, org_ankhmorpork, all_fixtures) -> None: """Assert that profile_query() returns a query.""" assert isinstance( - cast(SearchInProfileProvider, search_providers[stype]).profile_query( - "test", org_ankhmorpork.profile + cast(SearchInAccountProvider, search_providers[stype]).account_query( + get_tsquery("test"), org_ankhmorpork ), Query, ) @@ -83,7 +96,7 @@ def test_search_project_returns_query(stype, project_expo2010, all_fixtures) -> """Assert that project_query() returns an int.""" assert isinstance( cast(SearchInProjectProvider, search_providers[stype]).project_query( - "test", project_expo2010 + get_tsquery("test"), project_expo2010 ), Query, ) @@ -95,9 +108,9 @@ def test_search_project_returns_query(stype, project_expo2010, all_fixtures) -> @pytest.mark.usefixtures('request_context', 'all_fixtures') def test_search_counts(org_ankhmorpork, project_expo2010) -> None: """Test that search_counts returns a list of dicts.""" - r1 = search_counts("test") - r2 = search_counts("test", profile=org_ankhmorpork.profile) - r3 = search_counts("test", project=project_expo2010) + r1 = search_counts(get_tsquery("test")) + r2 = search_counts(get_tsquery("test"), account=org_ankhmorpork) + r3 = search_counts(get_tsquery("test"), project=project_expo2010) for resultset in (r1, r2, r3): assert isinstance(resultset, list) @@ -113,14 +126,14 @@ def test_search_counts(org_ankhmorpork, project_expo2010) -> None: @pytest.mark.usefixtures('app_context', 'all_fixtures') def test_view_search_counts(app, client, org_ankhmorpork, project_expo2010) -> None: """Search views return counts as a list of dicts.""" - org_ankhmorpork.profile.make_public() + org_ankhmorpork.make_profile_public() r1 = client.get( - url_for('SearchView_search'), + url_for('search'), query_string={'q': "test"}, headers={'Accept': 'application/json'}, ).get_json() r2 = client.get( - org_ankhmorpork.profile.url_for('search'), + org_ankhmorpork.url_for('search'), query_string={'q': "test"}, headers={'Accept': 'application/json'}, ).get_json() @@ -144,7 +157,7 @@ def test_view_search_counts(app, client, org_ankhmorpork, project_expo2010) -> N def test_view_search_results_all(client, stype) -> None: """Global search view returns results for each type.""" resultset = client.get( - url_for('SearchView_search'), + url_for('search'), query_string={'q': "test", 'type': stype}, headers={'Accept': 'application/json'}, ).get_json() @@ -161,9 +174,9 @@ def test_view_search_results_all(client, stype) -> None: @pytest.mark.parametrize('stype', search_profile_types) def test_view_search_results_profile(client, org_ankhmorpork, stype) -> None: """Account search view returns results for each type.""" - org_ankhmorpork.profile.make_public() + org_ankhmorpork.make_profile_public() resultset = client.get( - org_ankhmorpork.profile.url_for('search'), + org_ankhmorpork.url_for('search'), query_string={'q': "test", 'type': stype}, headers={'Accept': 'application/json'}, ).get_json() diff --git a/tests/unit/views/test_session_temp_vars.py b/tests/unit/views/session_temp_vars_test.py similarity index 98% rename from tests/unit/views/test_session_temp_vars.py rename to tests/unit/views/session_temp_vars_test.py index 1f14896b6..2ceed93ea 100644 --- a/tests/unit/views/test_session_temp_vars.py +++ b/tests/unit/views/session_temp_vars_test.py @@ -1,11 +1,13 @@ """Test handling of temporary variables in cookie session.""" +# pylint: disable=redefined-outer-name -from datetime import timedelta import time +from datetime import timedelta import pytest from coaster.utils import utcnow + from funnel.views.helpers import SessionTimeouts, session_timeouts test_timeout_seconds = 1 diff --git a/tests/unit/views/test_shortlink.py b/tests/unit/views/shortlink_test.py similarity index 97% rename from tests/unit/views/test_shortlink.py rename to tests/unit/views/shortlink_test.py index d91bb1f90..182ff14ac 100644 --- a/tests/unit/views/test_shortlink.py +++ b/tests/unit/views/shortlink_test.py @@ -1,4 +1,5 @@ """Test shortlink views.""" +# pylint: disable=redefined-outer-name from urllib.parse import urlsplit diff --git a/tests/unit/views/siteadmin_test.py b/tests/unit/views/siteadmin_test.py new file mode 100644 index 000000000..35675f3d0 --- /dev/null +++ b/tests/unit/views/siteadmin_test.py @@ -0,0 +1,50 @@ +"""Test siteadmin endpoints.""" +# pylint: disable=redefined-outer-name + +from __future__ import annotations + +import pytest + +from funnel import models + + +@pytest.fixture() +def rq_dashboard(): + """Run tests for rq_dashboard only if it is installed.""" + return pytest.importorskip('rq_dashboard') + + +@pytest.fixture() +def user_vetinari_sysadmin( + db_session, user_vetinari: models.User +) -> models.SiteMembership: + if user_vetinari.active_site_membership: + site_membership = user_vetinari.active_site_membership.replace( + actor=user_vetinari, is_sysadmin=True + ) + else: + site_membership = models.SiteMembership( + granted_by=user_vetinari, member=user_vetinari, is_sysadmin=True + ) + db_session.add(site_membership) + return site_membership + + +@pytest.mark.usefixtures('rq_dashboard') +def test_cant_access_rq_dashboard( + app, client, login, user_rincewind: models.User +) -> None: + """User who is not a sysadmin cannot access RQ dashboard.""" + login.as_(user_rincewind) + rv = client.get(app.url_for('rq_dashboard.queues_overview')) + assert rv.status_code == 403 + + +@pytest.mark.usefixtures('rq_dashboard', 'user_vetinari_sysadmin') +def test_can_access_rq_dashboard( + app, client, login, user_vetinari: models.User +) -> None: + """User who is a sysadmin can access RQ dashboard.""" + login.as_(user_vetinari) + rv = client.get(app.url_for('rq_dashboard.queues_overview')) + assert rv.status_code == 200 diff --git a/tests/unit/views/test_sitemap.py b/tests/unit/views/sitemap_test.py similarity index 99% rename from tests/unit/views/test_sitemap.py rename to tests/unit/views/sitemap_test.py index c62253941..8795d671b 100644 --- a/tests/unit/views/test_sitemap.py +++ b/tests/unit/views/sitemap_test.py @@ -2,13 +2,13 @@ from datetime import datetime, timedelta -from werkzeug.exceptions import NotFound - +import pytest from dateutil.relativedelta import relativedelta from pytz import utc -import pytest +from werkzeug.exceptions import NotFound from coaster.utils import utcnow + from funnel.views import sitemap @@ -208,6 +208,7 @@ def test_changefreq_for_age() -> None: assert sitemap.changefreq_for_age(timedelta(days=180)) == sitemap.ChangeFreq.yearly +@pytest.mark.dbcommit() def test_sitemap(client) -> None: """Test sitemap endpoints (caveat: no content checks).""" expected_content_type = 'application/xml; charset=utf-8' diff --git a/tests/unit/views/test_notification.py b/tests/unit/views/test_notification.py deleted file mode 100644 index 06af6485f..000000000 --- a/tests/unit/views/test_notification.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Test Notification views.""" - -from urllib.parse import urlsplit - -from flask import url_for - -import pytest - -from funnel import models - - -@pytest.fixture() -def phone_vetinari(db_session, user_vetinari): - """Add a phone number to user_vetinari.""" - userphone = user_vetinari.add_phone('+12345678900') - db_session.add(userphone) - db_session.commit() - return userphone - - -@pytest.fixture() -def notification_prefs_vetinari(db_session, user_vetinari): - """Add main notification preferences for user_vetinari.""" - prefs = models.NotificationPreferences( - user=user_vetinari, - notification_type='', - by_email=True, - by_sms=True, - by_webpush=True, - by_telegram=True, - by_whatsapp=True, - ) - db_session.add(prefs) - db_session.commit() - return prefs - - -@pytest.fixture() -def project_update(db_session, user_vetinari, project_expo2010): - """Create an update to add a notification for.""" - db_session.commit() - update = models.Update( - project=project_expo2010, - user=user_vetinari, - title="New update", - body="New update body", - ) - db_session.add(update) - db_session.commit() - update.publish(user_vetinari) - db_session.commit() - return update - - -@pytest.fixture() -def update_user_notification(db_session, user_vetinari, project_update): - """Get a user notification for the update fixture.""" - notification = models.NewUpdateNotification(project_update) - db_session.add(notification) - db_session.commit() - - # Extract all the user notifications - all_user_notifications = list(notification.dispatch()) - db_session.commit() - # There should be only one, assigned to Vetinari, but we'll let the test confirm - return all_user_notifications[0] - - -def test_user_notification_is_for_user_vetinari( - update_user_notification, user_vetinari -) -> None: - """Confirm the test notification is for the test user fixture.""" - assert update_user_notification.user == user_vetinari - - -@pytest.fixture() -def unsubscribe_sms_short_url( - update_user_notification, phone_vetinari, notification_prefs_vetinari -): - """Get an unsubscribe URL for the SMS notification.""" - return update_user_notification.views.render.unsubscribe_short_url('sms') - - -def test_unsubscribe_view_is_well_formatted(unsubscribe_sms_short_url) -> None: - """Confirm the SMS unsubscribe URL is well formatted.""" - prefix = 'https://bye.test/' - assert unsubscribe_sms_short_url.startswith(prefix) - assert len(unsubscribe_sms_short_url) == len(prefix) + 4 # 4 char random value - - -def test_unsubscribe_sms_view( - app, client, unsubscribe_sms_short_url, user_vetinari -) -> None: - """Confirm the unsubscribe URL renders a form.""" - unsub_url = url_for( - 'notification_unsubscribe_short', - token=urlsplit(unsubscribe_sms_short_url).path[1:], - _external=True, - ) - - # Get the unsubscribe URL. This should cause a cookie to be set, with a - # redirect to the same URL and `?cookietest=1` appended - rv = client.get(unsub_url) - assert rv.status_code == 302 - assert rv.location.startswith(unsub_url) - assert rv.location.endswith('cookietest=1') - - # Follow the redirect. This will cause yet another redirect - rv = client.get(rv.location) - assert rv.status_code == 302 - # Werkzeug 2.1 defaults to relative URLs in redirects as per the change in RFC 7231: - # https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.2 - # https://github.com/pallets/werkzeug/issues/2352 - # Earlier versions of Werkzeug defaulted to RFC 2616 behaviour for an absolute URL: - # https://datatracker.ietf.org/doc/html/rfc2616#section-14.30 - # This test will fail on Werkzeug < 2.1 - assert rv.location == url_for('notification_unsubscribe_do', _external=False) - - # This time we'll get the unsubscribe form. - rv = client.get(rv.location) - assert rv.status_code == 200 - - # Assert the user has SMS notifications enabled, and the form agrees - assert user_vetinari.main_notification_preferences.by_sms is True - form = rv.form('form-unsubscribe-preferences') - assert form.fields['main'] == 'y' - form.fields['main'] = False - rv = form.submit(client) - # We'll now get an acknowledgement - assert rv.status_code == 200 - # And the user's preferences will be turned off - assert user_vetinari.main_notification_preferences.by_sms is False diff --git a/tests/unit/views/unsubscribe_short_test.py b/tests/unit/views/unsubscribe_short_test.py new file mode 100644 index 000000000..0fbccdd99 --- /dev/null +++ b/tests/unit/views/unsubscribe_short_test.py @@ -0,0 +1,38 @@ +"""Test for the unsubscribe short URL handler redirecting to main URL.""" + +from secrets import token_urlsafe + +from flask import Flask + + +def test_unsubscribe_app_index(app: Flask, unsubscribeapp: Flask) -> None: + """Unsubscribe app redirects from index to main app's notification preferences.""" + with unsubscribeapp.test_client() as client: + rv = client.get('/') + assert rv.status_code == 301 + redirect_url: str = rv.location + assert redirect_url.startswith(('http://', 'https://')) + assert ( + app.url_for( + 'notification_preferences', utm_medium='sms', _anchor='sms', _external=True + ) + == redirect_url + ) + + +def test_unsubscribe_app_url_redirect(app: Flask, unsubscribeapp: Flask) -> None: + """Unsubscribe app does a simple redirect to main app's unsubscribe URL.""" + random_token = token_urlsafe(3) + assert random_token is not None + assert len(random_token) >= 4 + with unsubscribeapp.test_client() as client: + rv = client.get(f'/{random_token}') + assert rv.status_code == 301 + redirect_url: str = rv.location + assert redirect_url.startswith(('http://', 'https://')) + assert ( + app.url_for( + 'notification_unsubscribe_short', token=random_token, _external=True + ) + == redirect_url + ) diff --git a/tests/unit/views/test_video.py b/tests/unit/views/video_test.py similarity index 95% rename from tests/unit/views/test_video.py rename to tests/unit/views/video_test.py index 365c7bff6..affe38da0 100644 --- a/tests/unit/views/test_video.py +++ b/tests/unit/views/video_test.py @@ -1,11 +1,11 @@ """Test embedded video view helpers.""" -from datetime import datetime import logging +from datetime import datetime -from pytz import utc import pytest import requests +from pytz import utc from funnel import models @@ -43,8 +43,8 @@ def test_youtube_video_delete(db_session, new_proposal) -> None: assert new_proposal.video_id is None -@pytest.mark.remote_data() -@pytest.mark.requires_config('youtube') +@pytest.mark.enable_socket() +@pytest.mark.requires_config('app', 'youtube') @pytest.mark.usefixtures('app_context') def test_youtube(db_session, new_proposal) -> None: assert new_proposal.title == "Test Proposal" @@ -88,8 +88,8 @@ def test_vimeo_video_delete(db_session, new_proposal) -> None: assert new_proposal.video_id is None -@pytest.mark.remote_data() -@pytest.mark.requires_config('vimeo') +@pytest.mark.enable_socket() +@pytest.mark.requires_config('app', 'vimeo') @pytest.mark.usefixtures('app_context') def test_vimeo(db_session, new_proposal) -> None: assert new_proposal.title == "Test Proposal" @@ -114,7 +114,7 @@ def test_vimeo(db_session, new_proposal) -> None: assert check_video['thumbnail'].startswith('https://i.vimeocdn.com/video/783856813') -@pytest.mark.requires_config('vimeo') +@pytest.mark.requires_config('app', 'vimeo') @pytest.mark.usefixtures('app_context') def test_vimeo_request_exception(caplog, requests_mock, new_proposal) -> None: caplog.set_level(logging.WARNING) @@ -128,6 +128,7 @@ def test_vimeo_request_exception(caplog, requests_mock, new_proposal) -> None: @pytest.mark.usefixtures('app_context') +@pytest.mark.mock_config('app', {'YOUTUBE_API_KEY': ''}) def test_youtube_request_exception(caplog, requests_mock, new_proposal) -> None: caplog.set_level(logging.WARNING) requests_mock.get( diff --git a/webpack.config.js b/webpack.config.js index ef3322ef5..5403a756e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,7 +3,6 @@ process.traceDeprecation = true; const webpack = require('webpack'); const ESLintPlugin = require('eslint-webpack-plugin'); const path = require('path'); -const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const { InjectManifest } = require('workbox-webpack-plugin'); const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); @@ -17,6 +16,10 @@ module.exports = { path: require.resolve('path-browserify'), }, }, + cache: { + type: 'filesystem', + cacheDirectory: path.resolve(__dirname, '.webpack_cache'), + }, devtool: 'source-map', externals: { jquery: 'jQuery', @@ -45,11 +48,13 @@ module.exports = { 'funnel/assets/js/notification_settings.js' ), account_saved: path.resolve(__dirname, 'funnel/assets/js/account_saved.js'), + autosave_form: path.resolve(__dirname, 'funnel/assets/js/autosave_form.js'), form: path.resolve(__dirname, 'funnel/assets/js/form.js'), account_form: path.resolve(__dirname, 'funnel/assets/js/account_form.js'), submission_form: path.resolve(__dirname, 'funnel/assets/js/submission_form.js'), labels_form: path.resolve(__dirname, 'funnel/assets/js/labels_form.js'), cfp_form: path.resolve(__dirname, 'funnel/assets/js/cfp_form.js'), + rsvp_form_modal: path.resolve(__dirname, 'funnel/assets/js/rsvp_form_modal.js'), app_css: path.resolve(__dirname, 'funnel/assets/sass/app.scss'), form_css: path.resolve(__dirname, 'funnel/assets/sass/form.scss'), index_css: path.resolve(__dirname, 'funnel/assets/sass/pages/index.scss'), @@ -96,6 +101,8 @@ module.exports = { loader: 'babel-loader', options: { plugins: ['@babel/plugin-syntax-dynamic-import'], + cacheCompression: false, + cacheDirectory: true, }, }, { @@ -114,6 +121,7 @@ module.exports = { plugins: [ new ESLintPlugin({ fix: true, + cache: true, }), new CopyWebpackPlugin({ patterns: [ @@ -125,14 +133,15 @@ module.exports = { from: 'node_modules/leaflet/dist/images', to: 'css/images', }, + { + from: 'node_modules/prismjs/components/*.min.js', + to: 'js/prismjs/components/[name].js', + }, ], }), new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify(nodeEnv) }, }), - new CleanWebpackPlugin({ - root: path.join(__dirname, 'funnel/static'), - }), new WebpackManifestPlugin({ fileName: path.join(__dirname, 'funnel/static/build/manifest.json'), }), diff --git a/wsgi.py b/wsgi.py index ab7918184..9fe2cbe87 100644 --- a/wsgi.py +++ b/wsgi.py @@ -3,9 +3,21 @@ import os.path import sys -__all__ = ['application', 'shortlinkapp'] +from flask.cli import load_dotenv +from flask.helpers import get_load_dotenv +from werkzeug.middleware.proxy_fix import ProxyFix + +__all__ = ['application', 'shortlinkapp', 'unsubscribeapp'] sys.path.insert(0, os.path.dirname(__file__)) +if get_load_dotenv(): + load_dotenv() + # pylint: disable=wrong-import-position -from funnel import app as application # isort:skip -from funnel import shortlinkapp # isort:skip +from funnel import app as application, shortlinkapp, unsubscribeapp # isort:skip + +application.wsgi_app = ProxyFix(application.wsgi_app) # type: ignore[method-assign] +shortlinkapp.wsgi_app = ProxyFix(shortlinkapp.wsgi_app) # type: ignore[method-assign] +unsubscribeapp.wsgi_app = ProxyFix( # type: ignore[method-assign] + unsubscribeapp.wsgi_app +)