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 b9025d79a..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 +*.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 3311f9faa..225d8256e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -6,37 +6,35 @@ name: Pytest on: push: - branches: ['main'] - pull_request: - branches: ['main'] paths: - '**.py' - '**.js' + - '**.scss' - '**.jinja2' + - '.flaskenv' + - '.testenv' + - 'package-lock.json' - 'requirements/base.txt' - 'requirements/test.txt' - workflow_call: - inputs: - requirements: - description: Updated requirements/base.txt - type: string - requirements_dev: - description: Updated requirements/dev.txt - type: string - requirements_test: - description: Updated requirements/test.txt - type: string + - '.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: @@ -58,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) @@ -70,39 +71,50 @@ jobs: 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/base.txt - if: ${{ inputs.requirements }} - run: | - echo ${{ inputs.requirements }} > requirements/base.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 Node and Python dependencies - run: | - python -m pip install --upgrade pip setuptools - make install-test - - name: Annotate Pytest failures in PR - run: pip install pytest-github-actions-annotate-failures - 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);" @@ -111,7 +123,7 @@ jobs: 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 39a384113..feb7b1e99 100644 --- a/.github/workflows/telegram.yml +++ b/.github/workflows/telegram.yml @@ -47,7 +47,7 @@ jobs: "StephanieBr": {"tguser": "@stephaniebrne"}, "vidya-ram": {"tguser": "@vidya_ramki"}, "zainabbawa": {"tguser": "@Saaweoh"}, - "anishtp": {"tguser": "@anishtp"}, + "anishTP": {"tguser": "@anishtp"}, ".*": {"tguser": "Unknown"} } export_to: env @@ -140,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 1e11e7497..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,8 @@ node_modules baseframe-packed.* # Download files -geoname_data +geoname_data/ +download/ # Dependencies build/dependencies @@ -54,15 +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 5885d5329..8d0ab4a4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,27 +2,34 @@ # 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-audit', '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/peterdemin/pip-compile-multi - rev: v2.6.2 + rev: v2.6.3 hooks: - id: pip-compile-multi-verify files: ^requirements/.*\.(in|txt)$ - repo: https://github.com/pypa/pip-audit - rev: v2.4.15 + rev: v2.6.1 hooks: - id: pip-audit args: [ + '--disable-pip', '--no-deps', - '--fix', + '--skip-editable', '-r', 'requirements/base.txt', '-r', @@ -33,8 +40,23 @@ repos: '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/.*\.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: @@ -47,18 +69,13 @@ 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 + # - flake8-annotations - flake8-assertive - flake8-blind-except - flake8-bugbear @@ -81,52 +98,56 @@ repos: additional_dependencies: - tomli - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.10.0 hooks: - id: black # Mypy is temporarily disabled until the SQLAlchemy 2.0 migration is complete # - repo: https://github.com/pre-commit/mirrors-mypy - # rev: v0.991 + # 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-all + # - 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.2 + 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-pytest - 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 @@ -152,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 - 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.15 - 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/.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 c16269425..2d35005d0 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ all: - @echo "You must have an active Python virtualenv (3.7+) before using any of these." + @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" @@ -10,7 +10,8 @@ all: @echo @echo "For development:" @echo " make install-dev # For first time setup and after dependency upgrades" - @echo " make deps # Scan for dependency upgrades (and test afterwards!)" + @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 @@ -23,7 +24,7 @@ all: 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 @@ -33,12 +34,28 @@ 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 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; @@ -55,10 +72,14 @@ deps-editable: 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 + pip-compile-multi --backtracking --live deps-python-base: deps-editable pip-compile-multi -t requirements/base.in --backtracking --use-cache @@ -75,6 +96,8 @@ deps-python-verify: deps-npm: npm update +deps-noup: deps-python-noup + deps: deps-python deps-npm install-npm: @@ -83,23 +106,32 @@ install-npm: install-npm-ci: npm clean-install -install-python-dev: deps-editable - pip install -r requirements/dev.txt +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: deps-editable - pip install -r requirements/test.txt +install-python-test: install-python-pip deps-editable + pip install --use-pep517 -r requirements/test.txt -install-python: deps-editable - pip install -r requirements/base.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-ci 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 7f5ab47e6..92666baeb 100644 --- a/funnel/assets/js/app.js +++ b/funnel/assets/js/app.js @@ -1,31 +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( @@ -41,66 +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'); @@ -111,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 ( @@ -137,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 89747670e..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 = $(formId).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 c0a71a2d9..48f3278d7 100644 --- a/funnel/assets/js/utils/codemirror.js +++ b/funnel/assets/js/utils/codemirror.js @@ -55,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/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 88b1c3afc..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,124 +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((response) => response.json()) - .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 1f3b3cd2f..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'); @@ -430,6 +215,18 @@ const Utils = { } 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/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 f224dbda6..137f9ab72 100644 --- a/funnel/assets/js/utils/markmap.js +++ b/funnel/assets/js/utils/markmap.js @@ -14,8 +14,7 @@ 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'); 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 ec161d5f5..7f58004fb 100644 --- a/funnel/assets/js/utils/prism.js +++ b/funnel/assets/js/utils/prism.js @@ -9,7 +9,7 @@ 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() { Prism.highlightElement(this); }); @@ -17,7 +17,7 @@ const PrismEmbed = { 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 ) { Prism.hooks.add('before-sanity-check', (env) => { 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 index 0888fe425..1a3259cc5 100644 --- a/funnel/assets/js/utils/tabs.js +++ b/funnel/assets/js/utils/tabs.js @@ -187,7 +187,7 @@ const Tabs = { async init(container) { const $parentElement = $(container || 'body'); const $tablists = $parentElement.find( - '[role=tablist]:not(.activating):not(.activated)' + '[role=tablist]:not(.activating, .activated)' ); $tablists.addClass('activating'); this.process($parentElement, $tablists); 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/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 70645979c..4184faf36 100644 --- a/funnel/assets/sass/components/_markdown.scss +++ b/funnel/assets/sass/components/_markdown.scss @@ -5,8 +5,6 @@ .markdown { overflow-wrap: break-word; overflow: auto; - margin: 0 -$mui-grid-padding; - padding: 0 $mui-grid-padding; h1, h2, @@ -17,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/_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 5fc963918..82aa73c03 100644 --- a/funnel/assets/sass/components/_tabs.scss +++ b/funnel/assets/sass/components/_tabs.scss @@ -35,7 +35,7 @@ .md-tablist-wrapper { display: flex; - margin: 0 -16px; + margin: -$mui-grid-padding * 0.5; align-items: center; > .mui-tabs__bar:not(.mui-tabs__bar--pills) { @@ -141,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; @@ -169,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; @@ -177,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; @@ -205,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; @@ -231,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, @@ -283,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; @@ -302,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; 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 09023057b..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,7 +398,7 @@ cursor: not-allowed; height: 32px !important; position: relative !important; - padding-left: $mui-grid-padding/2 !important; + padding-left: $mui-grid-padding * 0.5 !important; } .mui-select:focus > label, .mui-select > select:focus ~ label { @@ -419,3 +421,69 @@ .field-toggle { display: none; } + +// ============================================================================ +// 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/_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/_variables.scss b/funnel/assets/sass/mui/_variables.scss index 177233b8b..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; 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 41a3e2c93..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 { @@ -236,11 +235,12 @@ border: none; border-radius: 0; background: $mui-bg-color-primary; + min-height: 72px; // height of navbar on project page } } .user, .icon-btn { - margin: 0 $mui-grid-padding/2 0 0; + 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 bcdd71765..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; @@ -176,10 +176,10 @@ a.loginbutton.hidden, // ============================================================================ .field-toggle { - padding: $mui-grid-padding/2; + padding: $mui-grid-padding * 0.5; position: absolute; - right: -$mui-grid-padding/2; - bottom: -$mui-grid-padding/4; + right: -$mui-grid-padding * 0.5; + bottom: -$mui-grid-padding * 0.25; float: right; z-index: 3; } @@ -203,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; 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 1fa236ccb..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: 0 $mui-grid-padding $mui-grid-padding/4 !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,69 +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; - align-self: center; - } - .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; + } } } } @@ -127,35 +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__txt--bigger { - font-size: 16px; - } - .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; + } + } } } } @@ -242,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; @@ -250,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%; @@ -258,6 +279,9 @@ object-fit: cover; } } + .project-banner__profile-details__badge { + margin-left: auto; + } } .project-banner__profile-details--center { @@ -330,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 { @@ -416,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; @@ -456,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 { @@ -504,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; } @@ -569,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); } } @@ -602,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; @@ -621,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 f2f3ab427..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,6 +47,8 @@ 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 @@ -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 44b221339..21cdb251f 100644 --- a/funnel/devtest.py +++ b/funnel/devtest.py @@ -2,39 +2,36 @@ from __future__ import annotations -from secrets import token_urlsafe -from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Tuple import atexit import gc import inspect import multiprocessing import os -import platform import signal import socket import time import weakref - -from sqlalchemy.engine import Engine +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 typing_extensions import Protocol - -from . import app as main_app -from . import shortlinkapp, transports +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 ------------------------------------------ @@ -70,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 } @@ -98,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 ---------------------------------------------------------------- @@ -118,21 +115,21 @@ class HostPort(NamedTuple): class CapturedSms(NamedTuple): phone: str message: str - vars: Dict[str, str] # noqa: A003 + vars: dict[str, str] # noqa: A003 class CapturedEmail(NamedTuple): subject: str - to: List[str] + to: list[str] content: str - from_email: Optional[str] + from_email: str | None class CapturedCalls(Protocol): """Protocol class for captured calls.""" - email: List[CapturedEmail] - sms: List[CapturedSms] + email: list[CapturedEmail] + sms: list[CapturedSms] def _signature_without_annotations(func) -> inspect.Signature: @@ -146,7 +143,7 @@ def _signature_without_annotations(func) -> inspect.Signature: ) -def install_mock(func: Callable, mock: Callable): +def install_mock(func: Callable, mock: Callable) -> None: """ Patch all existing references to :attr:`func` with :attr:`mock`. @@ -157,7 +154,7 @@ def install_mock(func: Callable, mock: Callable): msig = _signature_without_annotations(mock) if fsig != msig: raise TypeError( - f"Mock function's signature does not match original's:\n" + f"Mock function’s signature does not match original’s:\n" f"{mock.__name__}{msig} !=\n" f"{func.__name__}{fsig}" ) @@ -176,13 +173,12 @@ def install_mock(func: Callable, mock: Callable): ref[key] = mock -def _prepare_subprocess( # pylint: disable=too-many-arguments - engines: Iterable[Engine], +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: """ Prepare a subprocess for hosting a worker. @@ -192,27 +188,29 @@ def _prepare_subprocess( # pylint: disable=too-many-arguments 3. Launch the worker """ # https://docs.sqlalchemy.org/en/20/core/pooling.html#pooling-multiprocessing - for e in engines: - e.dispose(close=False) + with main_app.app_context(): + for engine in db.engines.values(): + engine.dispose(close=False) if mock_transports: - def mock_email( # pylint: disable=too-many-arguments + def mock_email( subject: str, - to: List[Any], + to: list[Any], content: str, attachments=None, - from_email: Optional[Any] = None, - headers: Optional[dict] = None, + from_email: Any | None = None, + headers: dict | None = None, + base_url: str | None = None, ) -> str: - calls.email.append( - CapturedEmail( - subject, - [str(each) for each in to], - content, - str(from_email) if from_email else None, - ) + 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( @@ -220,13 +218,15 @@ def mock_sms( message: transports.sms.SmsTemplate, callback: bool = True, ) -> str: - calls.sms.append(CapturedSms(str(phone), str(message), message.vars())) + 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, mock_sms) + install_mock(transports.sms.send.send_sms, mock_sms) return worker(*args, **kwargs) @@ -246,12 +246,12 @@ class BackgroundWorker: :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: bool = True, daemon: bool = True, @@ -264,10 +264,10 @@ 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 = multiprocessing.Manager() + manager = mpcontext.Manager() self.calls: CapturedCalls = manager.Namespace() self.calls.email = manager.list() self.calls.sms = manager.list() @@ -277,12 +277,9 @@ def start(self) -> None: if self._process is not None: return - with main_app.app_context(): - db_engines = db.engines.values() - self._process = multiprocessing.Process( + self._process = mpcontext.Process( target=_prepare_subprocess, args=( - db_engines, self.mock_transports, self.calls, self.worker, @@ -326,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 @@ -375,6 +372,6 @@ def __enter__(self) -> BackgroundWorker: self.start() return self - def __exit__(self, exc_type, exc_value, traceback) -> None: + 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 d2064454e..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, @@ -533,8 +533,17 @@ class NewEmailAddressForm(forms.RecaptchaForm): }, ) + 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.""" @@ -550,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"), @@ -580,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 60dbb85ad..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,7 +62,7 @@ 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 ----------------------------------------------------------------------- @@ -94,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. @@ -121,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"), @@ -155,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 @@ -172,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: @@ -188,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 @@ -244,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` @@ -260,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): @@ -287,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.""" @@ -323,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 644748e2c..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,8 +66,8 @@ 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 @@ -160,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"), @@ -204,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 @@ -242,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 b15f4ca48..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,16 +69,38 @@ class ProjectBoxofficeForm(forms.Form): filters=[forms.filters.strip()], ) allow_rsvp = forms.BooleanField( - __("Allow rsvp"), + __("Allow free registrations"), default=False, - description=__("If checked, both free and buy tickets will shown on project"), ) is_subscription = forms.BooleanField( - __("This is a subscription"), + __("Paid tickets are for a subscription"), default=True, - description=__("If not checked, buy tickets button will be shown"), + ) + has_membership = forms.BooleanField( + __("Tickets on this project represent memberships to the account"), + default=False, + ) + 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): @@ -64,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()], ) @@ -116,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( @@ -126,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"), @@ -170,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 e6f053778..eb243c69d 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -1,63 +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 flask_sqlalchemy.model import DefaultMeta -from flask_sqlalchemy.model import Model as ModelBase from sqlalchemy.dialects import postgresql from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import Mapped, declarative_mixin, declared_attr -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.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, ) -json_type: postgresql.JSONB = mutable_json_type(dbtype=postgresql.JSONB, nested=True) -db = SQLAlchemy() +class Model(ModelBase, DeclarativeBase): + """Base for all models.""" + + __with_timezone__ = True + + +class GeonameModel(ModelBase, DeclarativeBase): + """Base for geoname models.""" + + __bind_key__ = 'geoname' + __with_timezone__ = True + -# 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 @@ -67,13 +82,16 @@ 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 f97ae2e09..cf8281010 100644 --- a/funnel/models/auth_client.py +++ b/funnel/models/auth_client.py @@ -2,36 +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 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 ..typing import OptionalMigratedTables -from . import BaseMixin, Mapped, UuidMixin, db, declarative_mixin, declared_attr, sa +from coaster.utils import buid as make_buid, newsecret, require_one_of, utcnow + +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', @@ -39,7 +40,7 @@ 'AuthClient', 'AuthClientCredential', 'AuthClientTeamPermissions', - 'AuthClientUserPermissions', + 'AuthClientPermissions', ] @@ -53,17 +54,19 @@ class ScopeMixin: @classmethod def _scope(cls) -> Mapped[str]: """Database column for storing scopes as a space-separated string.""" - return sa.Column('scope', sa.UnicodeText, nullable=cls.__scope_null_allowed__) + return sa.orm.mapped_column( + 'scope', sa.UnicodeText, nullable=cls.__scope_null_allowed__ + ) @property - def scope(self) -> Tuple[str, ...]: + def scope(self) -> Iterable[str]: """Represent scope column as a container of strings.""" if not self._scope: return () return tuple(sorted(self._scope.split())) @scope.setter - def scope(self, value: Optional[Union[str, Iterable]]) -> None: + def scope(self, value: str | Iterable | None) -> None: if value is None: if self.__scope_null_allowed__: self._scope = None @@ -75,81 +78,69 @@ def scope(self, value: Optional[Union[str, Iterable]]) -> None: if not self._scope and self.__scope_null_allowed__: self._scope = None - def add_scope(self, additional: Union[str, Iterable]) -> 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' - __allow_unmapped__ = True __scope_null_allowed__ = True - # TODO: merge columns into a profile_id column - #: User who owns this client - user_id: Mapped[int] = sa.Column( - sa.Integer, sa.ForeignKey('user.id'), nullable=True - ) - user: Mapped[Optional[User]] = with_roles( - sa.orm.relationship( - User, - foreign_keys=[user_id], - backref=sa.orm.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: Mapped[int] = sa.Column( - sa.Integer, sa.ForeignKey('organization.id'), nullable=True - ) - organization: Mapped[Optional[User]] = with_roles( - sa.orm.relationship( - Organization, - foreign_keys=[organization_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_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: Mapped[str] = 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 = with_roles( - sa.Column(sa.UnicodeText, nullable=True, default=''), rw={'owner'} + sa.orm.mapped_column(sa.UnicodeText, nullable=True, default=''), rw={'owner'} ) #: Active flag - active: Mapped[bool] = 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'}, ) @@ -160,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__ = { @@ -196,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 () @@ -208,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: @@ -225,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. @@ -277,27 +252,27 @@ def get(cls, buid: str) -> Optional[AuthClient]: 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 @@ -309,14 +284,13 @@ class AuthClientCredential(BaseMixin, db.Model): # type: ignore[name-defined] """ __tablename__ = 'auth_client_credential' - __allow_unmapped__ = True - auth_client_id: Mapped[int] = sa.Column( + auth_client_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False ) auth_client: Mapped[AuthClient] = with_roles( - sa.orm.relationship( + relationship( AuthClient, - backref=sa.orm.backref( + backref=backref( 'credentials', cascade='all, delete-orphan', collection_class=attribute_keyed_dict('name'), @@ -326,23 +300,27 @@ class AuthClientCredential(BaseMixin, db.Model): # type: ignore[name-defined] ) #: OAuth client key - name: Mapped[str] = sa.Column( + name: Mapped[str] = sa.orm.mapped_column( sa.String(22), nullable=False, unique=True, default=make_buid ) #: User description for this credential - title: Mapped[str] = 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: Mapped[str] = 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: Mapped[datetime] = sa.Column( + accessed_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) - def __repr__(self): + 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 @@ -363,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(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). @@ -389,30 +367,31 @@ def new(cls, auth_client: AuthClient): 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' - __allow_unmapped__ = True - user_id: Mapped[int] = sa.Column( - sa.Integer, sa.ForeignKey('user.id'), nullable=False + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False ) - user: Mapped[User] = sa.orm.relationship(User, foreign_keys=[user_id]) - auth_client_id: Mapped[int] = sa.Column( + 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: Mapped[AuthClient] = sa.orm.relationship( + auth_client: Mapped[AuthClient] = relationship( AuthClient, foreign_keys=[auth_client_id], - backref=sa.orm.backref('authcodes', cascade='all'), + backref=backref('authcodes', cascade='all'), ) - user_session_id: Mapped[int] = sa.Column( - sa.Integer, sa.ForeignKey('user_session.id'), nullable=True + login_session_id: Mapped[int | None] = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('login_session.id'), nullable=True ) - user_session: Mapped[Optional[UserSession]] = sa.orm.relationship(UserSession) - code: Mapped[str] = sa.Column(sa.String(44), default=newsecret, nullable=False) - redirect_uri: Mapped[str] = sa.Column(sa.UnicodeText, nullable=False) - used: Mapped[bool] = sa.Column(sa.Boolean, default=False, nullable=False) + login_session: Mapped[LoginSession | None] = relationship(LoginSession) + code: Mapped[str] = sa.orm.mapped_column( + sa.String(44), default=newsecret, 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.""" @@ -421,87 +400,84 @@ 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(cls.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( 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' - __allow_unmapped__ = True - # 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, - 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: Mapped[AuthClient] = with_roles( - sa.orm.relationship( + relationship( AuthClient, - 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(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 effective_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 + if self.login_session: + return self.login_session.account + return self.account def __init__(self, **kwargs) -> None: super().__init__(**kwargs) @@ -512,10 +488,10 @@ def __init__(self, **kwargs) -> None: 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)) @@ -524,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() ) @@ -541,7 +517,7 @@ def refresh(self) -> None: self.secret = newsecret() @sa.orm.validates('algorithm') - def _validate_algorithm(self, _key: str, value: Optional[str]) -> Optional[str]: + 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.secret = None @@ -560,31 +536,29 @@ 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(cls.user == old_user).all() - newtokens: Dict[int, List[AuthToken]] = {} # AuthClient: token mapping - for token in cls.query.filter(cls.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. @@ -594,14 +568,14 @@ def get(cls, token: str) -> Optional[AuthToken]: @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 @@ -609,99 +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: + 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.user == user + cls.auth_client == auth_client, cls.account == account ).one_or_none() return cls.query.filter( - cls.auth_client == auth_client, cls.user_session == user_session + 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.""" + 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(users, QueryBaseClass): - count = users.count() + if isinstance(accounts, QueryBaseClass): + count = accounts.count() if count == 1: - return query.filter(AuthToken.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(User.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(AuthToken.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(cls.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.""" - - __tablename__ = 'auth_client_user_permissions' - __allow_unmapped__ = True - #: User who has these permissions - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user = sa.orm.relationship( - User, - foreign_keys=[user_id], - backref=sa.orm.backref('client_permissions', cascade='all'), +class AuthClientPermissions(BaseMixin, Model): + """Permissions assigned to an account on a client app.""" + + __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: Mapped[AuthClient] = with_roles( - sa.orm.relationship( + relationship( AuthClient, foreign_keys=[auth_client_id], - backref=sa.orm.backref('user_permissions', cascade='all'), + 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(' ')) @@ -712,56 +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.""" + 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.user == user + 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(cls.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(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' - __allow_unmapped__ = True #: 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, foreign_keys=[team_id], - backref=sa.orm.backref('client_permissions', cascade='all'), + 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: Mapped[AuthClient] = with_roles( - sa.orm.relationship( + relationship( AuthClient, foreign_keys=[auth_client_id], - backref=sa.orm.backref('team_permissions', cascade='all'), + 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 ) @@ -777,34 +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( 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.""" + 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 user.teams]), + 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(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 4c40b8d65..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,11 +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' - __allow_unmapped__ = True #: Commentset state code - _state = sa.Column( + _state = sa.orm.mapped_column( 'state', sa.SmallInteger, StateManager.check_constraint('state', COMMENTSET_STATE), @@ -87,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: Mapped[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: Mapped[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'}, ) @@ -133,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__ @@ -152,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) @@ -163,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: @@ -171,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, @@ -191,36 +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' - __allow_unmapped__ = True - 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: Mapped[List[Comment]] = 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), @@ -230,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: Mapped[TSVectorType] = 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__ = ( @@ -261,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__ = { @@ -276,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 @@ -290,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 @@ -322,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 @@ -347,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: @@ -378,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 @@ -400,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') @@ -412,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 efddba82f..4becbd934 100644 --- a/funnel/models/commentset_membership.py +++ b/funnel/models/commentset_membership.py @@ -2,41 +2,34 @@ from __future__ import annotations -from typing import Set -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - from werkzeug.utils import cached_property -from coaster.sqlalchemy import DynamicAssociationProxy, Query, with_roles +from coaster.sqlalchemy import DynamicAssociationProxy, with_roles -from . import Mapped, 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 from .project import Project from .proposal import Proposal from .update import Update -from .user import User __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' - __allow_unmapped__ = True __data_columns__ = ('last_seen_at', 'is_muted') __roles__ = { - 'subject': { + 'member': { 'read': { 'urls', - 'user', + 'member', 'commentset', 'is_muted', 'last_seen_at', @@ -45,14 +38,14 @@ class CommentsetMembership( } } - commentset_id: Mapped[int] = sa.Column( + commentset_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id', ondelete='CASCADE'), nullable=False, ) - commentset: Mapped[Commentset] = sa.orm.relationship( + commentset: Mapped[Commentset] = relationship( Commentset, - backref=sa.orm.backref( + backref=backref( 'subscriber_memberships', lazy='dynamic', cascade='all', @@ -60,14 +53,14 @@ class CommentsetMembership( ), ) - parent_id: int = sa.orm.synonym('commentset_id') + parent_id: Mapped[int] = sa.orm.synonym('commentset_id') parent_id_column = 'commentset_id' - parent: Commentset = sa.orm.synonym('commentset') + 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() ) @@ -81,7 +74,7 @@ class CommentsetMembership( ) @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 2d5ce0b57..c9e18974b 100644 --- a/funnel/models/contact_exchange.py +++ b/funnel/models/contact_exchange.py @@ -2,25 +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 sqlalchemy.ext.associationproxy import association_proxy - 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 Mapped, 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'] @@ -46,22 +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' - __allow_unmapped__ = True #: 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: Mapped[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()', @@ -69,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: Mapped[TicketParticipant] = 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', @@ -99,37 +101,37 @@ 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.""" subq = sa.select( cls.scanned_at.label('scanned_at'), @@ -140,7 +142,7 @@ def grouped_counts_for(cls, user, archived=False): ).filter( cls.ticket_participant_id == TicketParticipant.id, TicketParticipant.project_id == Project.id, - cls.user == user, + cls.account == account, ) if not archived: @@ -199,7 +201,7 @@ def grouped_counts_for(cls, user, archived=False): # WHERE # contact_exchange.ticket_participant_id = ticket_participant.id # AND ticket_participant.project_id = project.id - # AND :user_id = contact_exchange.user_id + # AND :account_id = contact_exchange.account_id # AND contact_exchange.archived IS false # ) AS anon_1 # GROUP BY project_id, project_uuid, project_title, project_timezone, scan_date @@ -228,7 +230,7 @@ def grouped_counts_for(cls, user, archived=False): r.scan_date, r.count, cls.contacts_for_project_and_date( - user, k, r.scan_date, archived + account, k, r.scan_date, archived ), ) for r in g @@ -250,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 @@ -276,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, @@ -291,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 589300dab..d51f79b92 100644 --- a/funnel/models/draft.py +++ b/funnel/models/draft.py @@ -2,33 +2,31 @@ from __future__ import annotations -from typing import Optional from uuid import UUID from werkzeug.datastructures import MultiDict -from . import Mapped, 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' - __allow_unmapped__ = True - table = sa.Column(sa.UnicodeText, primary_key=True) - table_row_id: Mapped[UUID] = sa.Column(UUIDType(binary=False), primary_key=True) - body = sa.Column(json_type, nullable=False, server_default='{}') - revision: Mapped[Optional[UUID]] = 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 181cd18f3..986f41be2 100644 --- a/funnel/models/email_address.py +++ b/funnel/models/email_address.py @@ -2,40 +2,33 @@ 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 . import ( BaseMixin, Mapped, + Model, + Query, db, declarative_mixin, declared_attr, hybrid_property, + relationship, sa, ) @@ -70,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. @@ -155,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. @@ -175,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. @@ -186,21 +180,20 @@ class EmailAddress(BaseMixin, db.Model): # type: ignore[name-defined] """ __tablename__ = 'email_address' - __allow_unmapped__ = True #: 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 @@ -208,7 +201,7 @@ 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( 'LENGTH(blake2b160) = 20', @@ -223,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( @@ -244,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 @@ -266,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', @@ -277,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 @@ -304,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. @@ -339,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 @@ -354,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 @@ -380,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__) @@ -434,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 @@ -452,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`. @@ -488,7 +489,7 @@ def get_filter( def get( cls, email: str, - ) -> Optional[EmailAddress]: + ) -> EmailAddress | None: ... @overload @@ -497,7 +498,7 @@ def get( cls, *, blake2b160: bytes, - ) -> Optional[EmailAddress]: + ) -> EmailAddress | None: ... @overload @@ -506,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. @@ -527,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. @@ -543,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. @@ -577,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. @@ -598,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 @@ -636,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' @@ -646,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(): @@ -660,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. @@ -697,20 +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 @classmethod - def email_address_id(cls) -> Mapped[int]: + 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__, @@ -726,10 +737,10 @@ def email_address(cls) -> Mapped[EmailAddress]: 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) + return relationship(EmailAddress, backref=backref_name) @property - def email(self) -> Optional[str]: + def email(self) -> str | None: """ Shorthand for ``self.email_address.email``. @@ -746,7 +757,7 @@ def email(self) -> Optional[str]: return None @email.setter - def email(self, value: Optional[str]) -> None: + def email(self, value: str | None) -> None: """Set an email address.""" if self.__email_for__: if value is not None: @@ -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: @@ -864,6 +884,12 @@ def _email_address_mixin_set_validator( @event.listens_for(EmailAddressMixin, 'mapper_configured', propagate=True) -def _email_address_mixin_configure_events(mapper_, cls: EmailAddressMixin): +def _email_address_mixin_configure_events( + _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 a1d2b751a..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 -from uuid import UUID # noqa: F401 # pylint: disable=unused-import import re +from collections.abc import Collection +from decimal import Decimal +from typing import cast from sqlalchemy.dialects.postgresql import ARRAY -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,37 +42,41 @@ } -class GeoCountryInfo(BaseNameMixin, db.Model): # type: ignore[name-defined] +class GeoCountryInfo(BaseNameMixin, GeonameModel): """Geoname record for a country.""" __tablename__ = 'geo_country_info' - __allow_unmapped__ = True - __bind_key__ = 'geoname' geonameid: Mapped[int] = sa.orm.synonym('id') - geoname: Mapped[GeoName] = sa.orm.relationship( + 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( @@ -76,98 +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' - __allow_unmapped__ = True - __bind_key__ = 'geoname' geonameid: Mapped[int] = sa.orm.synonym('id') - geoname: Mapped[GeoName] = sa.orm.relationship( + 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: Mapped[GeoCountryInfo] = 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' - __allow_unmapped__ = True - __bind_key__ = 'geoname' geonameid: Mapped[int] = sa.orm.synonym('id') - geoname: Mapped[GeoName] = sa.orm.relationship( + 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: Mapped[GeoCountryInfo] = 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' - __allow_unmapped__ = True - __bind_key__ = 'geoname' geonameid: Mapped[int] = 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( + 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: Mapped[GeoCountryInfo] = sa.orm.relationship('GeoCountryInfo') - cc2 = sa.Column(sa.Unicode) - admin1 = sa.Column(sa.Unicode) - admin1_ref: Mapped[Optional[GeoAdmin1Code]] = 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: Mapped[Optional[GeoAdmin2Code]] = 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), ' @@ -175,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( @@ -289,7 +298,7 @@ def make_name(self, reserved: Collection[str] = ()) -> None: usetitle = self.use_title 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) @@ -299,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() ) @@ -315,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: @@ -375,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. @@ -423,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. @@ -442,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: @@ -517,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), @@ -544,7 +553,7 @@ def parse_locations( return results @classmethod - def autocomplete(cls, prefix: str, lang: Optional[str] = None) -> Query: + def autocomplete(cls, prefix: str, lang: str | None = None) -> Query[GeoName]: """ Autocomplete a geoname record. @@ -567,24 +576,24 @@ def autocomplete(cls, prefix: 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' - __allow_unmapped__ = True - __bind_key__ = 'geoname' - geonameid = sa.Column(sa.Integer, sa.ForeignKey('geo_name.id'), nullable=False) - geoname: Mapped[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 1a227032e..3fb9d038e 100644 --- a/funnel/models/helpers.py +++ b/funnel/models/helpers.py @@ -2,42 +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, - cast, -) 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 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, sa +from ..utils import MarkdownConfig, MarkdownString, markdown_escape +from . import Model, UrlType, sa __all__ = [ 'RESERVED_NAMES', @@ -49,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', @@ -59,7 +46,7 @@ 'MarkdownCompositeInline', ] -RESERVED_NAMES: Set[str] = { +RESERVED_NAMES: set[str] = { '_baseframe', 'about', 'account', @@ -165,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 @@ -178,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) @@ -196,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) @@ -213,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. @@ -230,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}") @@ -278,7 +265,6 @@ def new_property(self): This decorator is intended to aid legibility of bi-directional relationships in SQLAlchemy models, specifically where a basic backref is augmented with methods or properties that do more processing. - """ def decorator(temp_cls: TempType) -> ReopenedType: @@ -319,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: @@ -334,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: @@ -342,55 +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) -> TSQUERY: +def quote_autocomplete_tsquery(prefix: str) -> TSQUERY: """Return a PostgreSQL tsquery suitable for autocomplete-type matches.""" return cast( TSQUERY, sa.func.cast( - sa.func.concat(sa.func.phraseto_tsquery(query or ''), ':*'), TSQUERY + sa.func.concat(sa.func.phraseto_tsquery('simple', prefix or ''), ':*'), + TSQUERY, ), ) -def add_search_trigger( - model: Any, 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: Mapped[TSVectorType] = 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( @@ -449,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};' ) @@ -499,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__()} @@ -527,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. @@ -547,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', []) @@ -562,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) @@ -631,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 - ): + 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, - group=group or name, + deferred_group=deferred_group, + **kwargs, + ) + col_html = sa.orm.mapped_column( + name + '_html', + sa.UnicodeText, + deferred=deferred, + 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 ef9530409..cb586b160 100644 --- a/funnel/models/label.py +++ b/funnel/models/label.py @@ -2,15 +2,19 @@ from __future__ import annotations -from typing import Union -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - -from sqlalchemy.ext.orderinglist import ordering_list -from sqlalchemy.sql import exists +from sqlalchemy.ext.orderinglist import OrderingList, ordering_list from coaster.sqlalchemy import with_roles -from . import BaseScopedNameMixin, Mapped, 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 @@ -18,7 +22,7 @@ proposal_label = sa.Table( 'proposal_label', - db.Model.metadata, # type: ignore[has-type] + Model.metadata, sa.Column( 'proposal_id', sa.Integer, @@ -38,19 +42,15 @@ ) -class Label( - BaseScopedNameMixin, - db.Model, # type: ignore[name-defined] -): +class Label(BaseScopedNameMixin, Model): __tablename__ = 'label' - __allow_unmapped__ = True - 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: Mapped[Project] = with_roles( - sa.orm.relationship(Project), grants_via={None: project_child_role_map} + relationship(Project), grants_via={None: project_child_role_map} ) # `parent` is required for # :meth:`~coaster.sqlalchemy.mixins.BaseScopedNameMixin.make_name()` @@ -59,16 +59,18 @@ class Label( #: 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), @@ -79,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: Mapped[TSVectorType] = 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: Mapped[Proposal] = sa.orm.relationship( + proposals: Mapped[list[Proposal]] = relationship( Proposal, secondary=proposal_label, back_populates='labels' ) @@ -180,49 +187,54 @@ def has_proposals(self) -> bool: @hybrid_property def restricted(self) -> bool: - return ( # pylint: disable=protected-access - self.main_label._restricted if self.main_label else self._restricted - ) + # pylint: disable=protected-access + return self.main_label._restricted if self.main_label else self._restricted - @restricted.setter - def restricted(self, value: bool) -> None: + @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 + @restricted.inplace.expression + @classmethod + def _restricted_expression(cls) -> sa.Case: + """Return SQL Expression.""" return sa.case( ( - cls.main_label_id.isnot(None), + cls.main_label_id.is_not(None), sa.select(Label._restricted) .where(Label.id == cls.main_label_id) - .as_scalar(), + .scalar_subquery(), ), else_=cls._restricted, ) @hybrid_property 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: bool) -> None: + @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 + @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.isnot(None), + cls.main_label_id.is_not(None), sa.select(Label._archived) .where(Label.id == cls.main_label_id) - .as_scalar(), + .scalar_subquery(), ), else_=cls._archived, ) @@ -231,9 +243,11 @@ def archived(cls): # noqa: N805 # pylint: disable=no-self-argument 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) -> bool: @@ -244,8 +258,8 @@ def required(self) -> bool: # pylint: disable=using-constant-test return self._required if self.has_options else False - @required.setter - def required(self, value: bool) -> None: + @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 @@ -309,7 +323,7 @@ class ProposalLabelProxyWrapper: def __init__(self, obj: Proposal) -> None: object.__setattr__(self, '_obj', obj) - def __getattr__(self, name: str) -> Union[bool, str, None]: + def __getattr__(self, name: str) -> bool | str | None: """Get an attribute.""" # What this does: # 1. Check if the project has this label (including archived labels). If not, @@ -373,9 +387,7 @@ def __setattr__(self, name: str, value: bool) -> None: class ProposalLabelProxy: - def __get__( - self, obj, cls=None - ) -> Union[ProposalLabelProxyWrapper, ProposalLabelProxy]: + def __get__(self, obj, cls=None) -> ProposalLabelProxyWrapper | ProposalLabelProxy: """Get proposal label proxy.""" if obj is not None: return ProposalLabelProxyWrapper(obj) @@ -384,7 +396,7 @@ def __get__( @reopen(Project) class __Project: - labels = sa.orm.relationship( + labels: Mapped[list[Label]] = relationship( Label, primaryjoin=sa.and_( Label.project_id == Project.id, @@ -394,7 +406,7 @@ class __Project: order_by=Label.seq, viewonly=True, ) - all_labels = sa.orm.relationship( + all_labels: Mapped[list[Label]] = relationship( Label, collection_class=ordering_list('seq', count_from=1), back_populates='project', @@ -406,9 +418,7 @@ class __Proposal: #: For reading and setting labels from the edit form formlabels = ProposalLabelProxy() - labels = with_roles( - sa.orm.relationship( - Label, secondary=proposal_label, back_populates='proposals' - ), + labels: Mapped[list[Label]] = with_roles( + relationship(Label, secondary=proposal_label, back_populates='proposals'), read={'all'}, ) diff --git a/funnel/models/login_session.py b/funnel/models/login_session.py new file mode 100644 index 000000000..e1d628cc5 --- /dev/null +++ b/funnel/models/login_session.py @@ -0,0 +1,190 @@ +"""Model for a user's auth (login) session.""" + +from __future__ import annotations + +from datetime import timedelta + +from coaster.utils import utcnow + +from ..signals import session_revoked +from . import ( + BaseMixin, + DynamicMapped, + Mapped, + Model, + UuidMixin, + backref, + relationship, + sa, +) +from .account import Account +from .helpers import reopen + +__all__ = [ + 'LoginSession', + 'LoginSessionError', + 'LoginSessionExpiredError', + 'LoginSessionRevokedError', + 'LoginSessionInactiveUserError', + 'auth_client_login_session', + 'LOGIN_SESSION_VALIDITY_PERIOD', +] + + +class LoginSessionError(Exception): + """Base exception for user session errors.""" + + +class LoginSessionExpiredError(LoginSessionError): + """This user session has expired and cannot be marked as currently active.""" + + +class LoginSessionRevokedError(LoginSessionError): + """This user session has been revoked and cannot be marked as currently active.""" + + +class LoginSessionInactiveUserError(LoginSessionError): + """This user is not in ACTIVE state and cannot have a currently active session.""" + + +LOGIN_SESSION_VALIDITY_PERIOD = timedelta(days=365) + +#: When a user logs into an client app, the user's session is logged against +#: the client app in this table +auth_client_login_session = sa.Table( + 'auth_client_login_session', + Model.metadata, + sa.Column( + 'auth_client_id', + sa.Integer, + sa.ForeignKey('auth_client.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'login_session_id', + sa.Integer, + sa.ForeignKey('login_session.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'created_at', + sa.TIMESTAMP(timezone=True), + nullable=False, + default=sa.func.utcnow(), + ), + sa.Column( + 'accessed_at', + sa.TIMESTAMP(timezone=True), + nullable=False, + default=sa.func.utcnow(), + ), +) + + +class LoginSession(UuidMixin, BaseMixin, Model): + __tablename__ = 'login_session' + + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship( + Account, + backref=backref('all_login_sessions', cascade='all', lazy='dynamic'), + ) + + #: User's last known IP address + ipaddr = sa.orm.mapped_column(sa.String(45), nullable=False) + #: City geonameid from IP address + geonameid_city = sa.orm.mapped_column(sa.Integer, nullable=True) + #: State/subdivision geonameid from IP address + geonameid_subdivision = sa.orm.mapped_column(sa.Integer, nullable=True) + #: Country geonameid from IP address + geonameid_country = sa.orm.mapped_column(sa.Integer, nullable=True) + #: User's network, from IP address + geoip_asn = sa.orm.mapped_column(sa.Integer, nullable=True) + #: User agent + user_agent = sa.orm.mapped_column(sa.UnicodeText, nullable=False) + #: The login service that was used to make this session + login_service = sa.orm.mapped_column(sa.Unicode, nullable=True) + + accessed_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=False) + revoked_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + sudo_enabled_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() + ) + + def __repr__(self) -> str: + """Represent :class:`UserSession` as a string.""" + return f'' + + @property + def has_sudo(self) -> bool: + return ( + self.sudo_enabled_at is None # New session, not yet written to db + or self.sudo_enabled_at > utcnow() - timedelta(minutes=15) + ) + + def set_sudo(self) -> None: + self.sudo_enabled_at = sa.func.utcnow() + + def revoke(self) -> None: + if not self.revoked_at: + self.revoked_at = sa.func.utcnow() + self.authtokens.delete(synchronize_session='fetch') + session_revoked.send(self) + + @classmethod + def get(cls, buid: str) -> LoginSession | None: + return cls.query.filter_by(buid=buid).one_or_none() + + @classmethod + def authenticate(cls, buid: str, silent: bool = False) -> LoginSession | None: + """ + Retrieve a user session that is supposed to be active. + + If a session is invalid, exceptions will be raised to indicate the problem, + unless silent mode is enabled. + """ + if silent: + return ( + cls.query.join(Account) + .filter( + # Session key must match. + cls.buid == buid, + # Sessions are valid for one year... + cls.accessed_at > sa.func.utcnow() - LOGIN_SESSION_VALIDITY_PERIOD, + # ...unless explicitly revoked (or user logged out). + cls.revoked_at.is_(None), + # Account must be active + Account.state.ACTIVE, + ) + .one_or_none() + ) + + # Not silent? Raise exceptions on expired and revoked sessions + login_session = cls.query.join(Account).filter(cls.buid == buid).one_or_none() + if login_session is not None: + if login_session.accessed_at <= utcnow() - LOGIN_SESSION_VALIDITY_PERIOD: + raise LoginSessionExpiredError(login_session) + if login_session.revoked_at is not None: + raise LoginSessionRevokedError(login_session) + if not login_session.account.state.ACTIVE: + raise LoginSessionInactiveUserError(login_session) + return login_session + + +@reopen(Account) +class __Account: + active_login_sessions: DynamicMapped[LoginSession] = relationship( + LoginSession, + lazy='dynamic', + primaryjoin=sa.and_( + LoginSession.account_id == Account.id, + LoginSession.accessed_at > sa.func.utcnow() - LOGIN_SESSION_VALIDITY_PERIOD, + LoginSession.revoked_at.is_(None), + ), + order_by=LoginSession.accessed_at.desc(), + viewonly=True, + ) diff --git a/funnel/models/mailer.py b/funnel/models/mailer.py new file mode 100644 index 000000000..035b8061b --- /dev/null +++ b/funnel/models/mailer.py @@ -0,0 +1,454 @@ +"""Mailer models.""" + +from __future__ import annotations + +import re +from collections.abc import Collection, Iterator +from datetime import datetime +from enum import IntEnum +from typing import Any +from uuid import UUID + +from flask import request +from markupsafe import Markup, escape +from premailer import transform as email_transform +from sqlalchemy.orm import defer + +from coaster.utils import MARKDOWN_HTML_TAGS, buid, md5sum, newsecret + +from .. import __ +from ..utils.markdown import MarkdownString, markdown_mailer +from ..utils.mustache import mustache_md +from . import ( + BaseNameMixin, + BaseScopedIdMixin, + DynamicMapped, + Mapped, + Model, + db, + relationship, + sa, +) +from .account import Account +from .helpers import reopen +from .types import jsonb + +__all__ = [ + 'MailerState', + 'Mailer', + 'MailerDraft', + 'MailerRecipient', +] + +NAMESPLIT_RE = re.compile(r'[\W\.]+') + +EMAIL_TAGS = dict(MARKDOWN_HTML_TAGS) +for _key in EMAIL_TAGS: + EMAIL_TAGS[_key].append('class') + EMAIL_TAGS[_key].append('style') + + +class MailerState(IntEnum): + """Send state for :class:`Mailer`.""" + + DRAFT = 0 + QUEUED = 1 + SENDING = 2 + SENT = 3 + + __titles__ = { + DRAFT: __("Draft"), + QUEUED: __("Queued"), + SENDING: __("Sending"), + SENT: __("Sent"), + } + + def __init__(self, value: int) -> None: + self.title = self.__titles__[value] + + +class Mailer(BaseNameMixin, Model): + """A mailer sent via email to multiple recipients.""" + + __tablename__ = 'mailer' + + user_uuid: Mapped[UUID] = sa.orm.mapped_column(sa.ForeignKey('account.uuid')) + user: Mapped[Account] = relationship(Account, back_populates='mailers') + status: Mapped[int] = sa.orm.mapped_column( + sa.Integer, nullable=False, default=MailerState.DRAFT + ) + _fields: Mapped[str] = sa.orm.mapped_column( + 'fields', sa.UnicodeText, nullable=False, default='' + ) + trackopens: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + stylesheet: Mapped[str] = sa.orm.mapped_column( + sa.UnicodeText, nullable=False, default='' + ) + _cc: Mapped[str] = sa.orm.mapped_column('cc', sa.UnicodeText, nullable=True) + _bcc: Mapped[str] = sa.orm.mapped_column('bcc', sa.UnicodeText, nullable=True) + + recipients: DynamicMapped[MailerRecipient] = relationship( + lazy='dynamic', + back_populates='mailer', + cascade='all, delete-orphan', + order_by='(MailerRecipient.draft_id, MailerRecipient._fullname,' + ' MailerRecipient._firstname, MailerRecipient._lastname)', + ) + drafts: Mapped[list[MailerDraft]] = relationship( + back_populates='mailer', + cascade='all, delete-orphan', + order_by='MailerDraft.url_id', + ) + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + if 'name' not in kwargs: # Use random name unless one was provided + self.name = buid() + + def __repr__(self) -> str: + return f'' + + @property + def fields(self) -> Collection[str]: + """Set or return template fields.""" + flist = self._fields.split(' ') + while '' in flist: + flist.remove('') + return tuple(flist) + + @fields.setter + def fields(self, value: Collection[str]) -> None: + self._fields = ' '.join(sorted(set(value))) + + @property + def cc(self) -> str: + """Set or return CC recipients on email.""" + return self._cc + + @cc.setter + def cc(self, value: str | Collection[str]) -> None: + if isinstance(value, str): + value = [ + _l.strip() + for _l in value.replace('\r\n', '\n').replace('\r', '\n').split('\n') + if _l + ] + self._cc = '\n'.join(sorted(set(value))) + + @property + def bcc(self) -> str: + """Set or return BCC recipients on email.""" + return self._bcc + + @bcc.setter + def bcc(self, value: str | Collection[str]) -> None: + if isinstance(value, str): + value = [ + _l.strip() + for _l in value.replace('\r\n', '\n').replace('\r', '\n').split('\n') + if _l + ] + self._bcc = '\n'.join(sorted(set(value))) + + def recipients_iter(self) -> Iterator[MailerRecipient]: + """Iterate through recipients.""" + ids = [ + i.id + for i in db.session.query(MailerRecipient.id) + .filter(MailerRecipient.mailer_id == self.id) + .order_by(MailerRecipient.id) + .all() + ] + for rid in ids: + recipient = MailerRecipient.query.get(rid) + if recipient: + yield recipient + + def permissions( + self, actor: Account | None, inherited: set[str] | None = None + ) -> set[str]: + perms = super().permissions(actor, inherited) + if actor is not None and actor == self.user: + perms.update(['edit', 'delete', 'send', 'new-recipient', 'report']) + return perms + + def draft(self) -> MailerDraft | None: + if self.drafts: + return self.drafts[-1] + return None + + def render_preview(self, text: str) -> str: + if self.stylesheet is not None and self.stylesheet.strip(): + stylesheet = f'\n' + else: + stylesheet = '' + rendered_text = Markup(stylesheet) + markdown_mailer.render(text) + if rendered_text: + # email_transform uses LXML, which does not like empty strings + return email_transform(rendered_text, base_url=request.url_root) + return '' + + +class MailerDraft(BaseScopedIdMixin, Model): + """Revision-controlled draft of mailer text (a Mustache template).""" + + __tablename__ = 'mailer_draft' + + mailer_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('mailer.id'), nullable=False + ) + mailer: Mapped[Mailer] = relationship(Mailer, back_populates='drafts') + parent: Mapped[Mailer] = sa.orm.synonym('mailer') + revision_id: Mapped[int] = sa.orm.synonym('url_id') + + subject: Mapped[str] = sa.orm.mapped_column( + sa.Unicode(250), nullable=False, default="", deferred=True + ) + + template: Mapped[str] = sa.orm.mapped_column( + sa.UnicodeText, nullable=False, default="", deferred=True + ) + + __table_args__ = (sa.UniqueConstraint('mailer_id', 'url_id'),) + + def __repr__(self) -> str: + return f'' + + def get_preview(self) -> str: + return self.mailer.render_preview(self.template) + + +class MailerRecipient(BaseScopedIdMixin, Model): + """Recipient of a mailer.""" + + __tablename__ = 'mailer_recipient' + + # Mailer this recipient is a part of + mailer_id: Mapped[int] = sa.orm.mapped_column(sa.ForeignKey('mailer.id')) + mailer: Mapped[Mailer] = relationship(Mailer, back_populates='recipients') + parent: Mapped[Mailer] = sa.orm.synonym('mailer') + + _fullname: Mapped[str | None] = sa.orm.mapped_column( + 'fullname', sa.Unicode(80), nullable=True + ) + _firstname: Mapped[str | None] = sa.orm.mapped_column( + 'firstname', sa.Unicode(80), nullable=True + ) + _lastname: Mapped[str | None] = sa.orm.mapped_column( + 'lastname', sa.Unicode(80), nullable=True + ) + _nickname: Mapped[str | None] = sa.orm.mapped_column( + 'nickname', sa.Unicode(80), nullable=True + ) + + _email: Mapped[str] = sa.orm.mapped_column( + 'email', sa.Unicode(80), nullable=False, index=True + ) + md5sum: Mapped[str] = sa.orm.mapped_column( + sa.String(32), nullable=False, index=True + ) + + data: Mapped[jsonb] = sa.orm.mapped_column() + + is_sent: Mapped[bool] = sa.orm.mapped_column(default=False) + + # Support email open tracking + opentoken: Mapped[str] = sa.orm.mapped_column( + sa.Unicode(44), nullable=False, default=newsecret, unique=True + ) + opened: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + opened_ipaddr: Mapped[str | None] = sa.orm.mapped_column( + sa.Unicode(45), nullable=True + ) + opened_first_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + opened_last_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + opened_count: Mapped[int] = sa.orm.mapped_column( + sa.Integer, nullable=False, default=0 + ) + + # Support RSVP if the email requires it + rsvptoken: Mapped[str] = sa.orm.mapped_column( + sa.Unicode(44), nullable=False, default=newsecret, unique=True + ) + # Y/N/M response + rsvp: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode(1), nullable=True) + + # Customised template for this recipient + subject: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode(250), nullable=True) + template: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True, deferred=True + ) + + # Rendered version of user's template, for archival + rendered_text: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True, deferred=True + ) + rendered_html: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True, deferred=True + ) + + # Draft of the mailer template that the custom template is linked to (for updating + # before finalising) + draft_id: Mapped[int | None] = sa.orm.mapped_column( + sa.ForeignKey('mailer_draft.id') + ) + draft: Mapped[MailerDraft | None] = relationship(MailerDraft) + + __table_args__ = (sa.UniqueConstraint('mailer_id', 'url_id'),) + + def __repr__(self) -> str: + return f'' + + @property + def fullname(self) -> str | None: + """Recipient's fullname, constructed from first and last names if required.""" + if self._fullname: + return self._fullname + if self._firstname: + if self._lastname: + # FIXME: Cultural assumption of name. + return f"{self._firstname} {self._lastname}" + return self._firstname + if self._lastname: + return self._lastname + return None + + @fullname.setter + def fullname(self, value: str | None) -> None: + self._fullname = value + + @property + def firstname(self) -> str | None: + if self._firstname: + return self._firstname + if self._fullname: + return NAMESPLIT_RE.split(self._fullname)[0] + return None + + @firstname.setter + def firstname(self, value: str | None) -> None: + self._firstname = value + + @property + def lastname(self) -> str | None: + if self._lastname: + return self._lastname + if self._fullname: + return NAMESPLIT_RE.split(self._fullname)[-1] + return None + + @lastname.setter + def lastname(self, value: str | None) -> None: + self._lastname = value + + @property + def nickname(self) -> str | None: + return self._nickname or self.firstname + + @nickname.setter + def nickname(self, value: str | None) -> None: + self._nickname = value + + @property + def email(self) -> str: + return self._email + + @email.setter + def email(self, value: str) -> None: + self._email = value.lower() + self.md5sum = md5sum(value) + + @property + def revision_id(self) -> int | None: + return self.draft.revision_id if self.draft else None + + def is_latest_draft(self) -> bool: + if not self.draft: + return True + return self.draft == self.mailer.draft() + + def template_data(self) -> dict[str, Any]: + tdata = { + 'fullname': self.fullname, + 'email': self.email, + 'firstname': self.firstname, + 'lastname': self.lastname, + 'nickname': self.nickname, + 'RSVP_Y': self.url_for('rsvp', status='Y', _external=True), + 'RSVP_N': self.url_for('rsvp', status='N', _external=True), + 'RSVP_M': self.url_for('rsvp', status='M', _external=True), + } + if self.data: + tdata.update(self.data) + return tdata + + def get_rendered(self) -> MarkdownString: + """Get Mustache-rendered Markdown text.""" + if self.draft: + return mustache_md(self.template or '', self.template_data()) + draft = self.mailer.draft() + if draft is not None: + return mustache_md(draft.template or '', self.template_data()) + return MarkdownString('') + + def get_preview(self) -> str: + """Get HTML preview.""" + return self.mailer.render_preview(self.get_rendered()) + + def openmarkup(self) -> Markup: + if self.mailer.trackopens: + return Markup( + f'\n' + ) + return Markup('') + + @property + def custom_draft(self) -> bool: + """Check if this recipient has a custom draft.""" + return self.draft is not None + + @classmethod + def custom_draft_in(cls, mailer: Mailer) -> list[MailerRecipient]: + return ( + cls.query.filter( + cls.mailer == mailer, + cls.draft.isnot(None), + ) + .options( + defer(cls.created_at), + defer(cls.updated_at), + defer(cls._email), + defer(cls.md5sum), + defer(cls._fullname), + defer(cls._firstname), + defer(cls._lastname), + defer(cls.data), + defer(cls.opentoken), + defer(cls.opened), + defer(cls.rsvptoken), + defer(cls.rsvp), + defer(cls._nickname), + ) + .all() + ) + + +@reopen(Account) +class __Account: + mailers: Mapped[list[Mailer]] = relationship( + Mailer, back_populates='user', order_by='Mailer.updated_at.desc()' + ) diff --git a/funnel/models/membership_mixin.py b/funnel/models/membership_mixin.py index 9766d012e..345b9ac89 100644 --- a/funnel/models/membership_mixin.py +++ b/funnel/models/membership_mixin.py @@ -2,42 +2,32 @@ from __future__ import annotations +from collections.abc import Callable, Iterable from datetime import datetime as datetime_type -from typing import ( - Any, - Callable, - ClassVar, - Generic, - Iterable, - Optional, - Set, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar from sqlalchemy import event from sqlalchemy.sql.expression import ColumnElement - from werkzeug.utils import cached_property from baseframe import __ from coaster.sqlalchemy import StateManager, immutable, with_roles from coaster.utils import LabeledEnum -from ..typing import ModelType, OptionalMigratedTables from . import ( BaseMixin, Mapped, + Model, UuidMixin, db, declarative_mixin, declared_attr, hybrid_property, + relationship, sa, ) -from .profile import Profile +from .account import Account from .reorder_mixin import ReorderMixin -from .user import EnumerateMembershipsMixin, User # Export only symbols needed in views. __all__ = [ @@ -51,7 +41,6 @@ MembershipType = TypeVar('MembershipType', bound='ImmutableMembershipMixin') FrozenAttributionType = TypeVar('FrozenAttributionType', bound='FrozenAttributionMixin') -SubjectType = Union[Mapped[User], Mapped[Profile]] # --- Enum ----------------------------------------------------------------------------- @@ -98,21 +87,25 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin): __uuid_primary_key__ = True #: Can granted_by be null? Only in memberships based on legacy data - __null_granted_by__ = False + __null_granted_by__: ClassVar[bool] = False #: List of columns that will be copied into a new row when a membership is amended __data_columns__: ClassVar[Iterable[str]] = () - #: Parent column (declare as synonym of 'profile_id' or 'project_id' in subclasses) - parent_id: Optional[int] #: Name of the parent id column, used in SQL constraints - parent_id_column: ClassVar[Optional[str]] - #: Parent object - parent: Optional[ModelType] - #: Subject of this membership (subclasses must define) - subject: SubjectType - - #: Should an active membership record be revoked when the subject is soft-deleted? + parent_id_column: ClassVar[str | None] + if TYPE_CHECKING: + #: Subclass has a table name + __tablename__: str + #: Parent column (declare as synonym of 'profile_id' or 'project_id' in + #: subclasses) + parent_id: Mapped[int] + #: Parent object + parent: Mapped[Model | None] + #: Subject of this membership (subclasses must define) + member: Mapped[Account] + + #: Should an active membership record be revoked when the member is soft-deleted? #: (Hard deletes will cascade and also delete all membership records.) - revoke_on_subject_delete: ClassVar[bool] = True + revoke_on_member_delete: ClassVar[bool] = True #: Internal flag for using only local data when replacing a record, used from #: :class:`FrozenAttributionMixin` @@ -122,72 +115,72 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin): #: for records created when the member table was added to the database granted_at: Mapped[datetime_type] = with_roles( immutable( - sa.Column( + sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) ), - read={'subject', 'editor'}, + read={'member', 'editor'}, ) #: End time of membership, ordinarily a mirror of updated_at - revoked_at: Mapped[Optional[datetime_type]] = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True), - read={'subject', 'editor'}, + revoked_at: Mapped[datetime_type | None] = with_roles( + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), + read={'member', 'editor'}, ) #: Record type record_type: Mapped[int] = with_roles( immutable( - sa.Column( + sa.orm.mapped_column( sa.Integer, StateManager.check_constraint('record_type', MEMBERSHIP_RECORD_TYPE), default=MEMBERSHIP_RECORD_TYPE.DIRECT_ADD, nullable=False, ) ), - read={'subject', 'editor'}, + read={'member', 'editor'}, ) @cached_property def record_type_label(self): return MEMBERSHIP_RECORD_TYPE[self.record_type] - with_roles(record_type_label, read={'subject', 'editor'}) + with_roles(record_type_label, read={'member', 'editor'}) @declared_attr @classmethod - def revoked_by_id(cls) -> Mapped[Optional[int]]: + def revoked_by_id(cls) -> Mapped[int | None]: """Id of user who revoked the membership.""" - return sa.Column( - sa.Integer, sa.ForeignKey('user.id', ondelete='SET NULL'), nullable=True + return sa.orm.mapped_column( + sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=True ) - @with_roles(read={'subject', 'editor'}, grants={'editor'}) + @with_roles(read={'member', 'editor'}, grants={'editor'}) @declared_attr @classmethod - def revoked_by(cls) -> Mapped[Optional[User]]: + def revoked_by(cls) -> Mapped[Account | None]: """User who revoked the membership.""" - return sa.orm.relationship(User, foreign_keys=[cls.revoked_by_id]) + return relationship(Account, foreign_keys=[cls.revoked_by_id]) @declared_attr @classmethod - def granted_by_id(cls) -> Mapped[int]: + def granted_by_id(cls) -> Mapped[int | None]: """ Id of user who assigned the membership. This is nullable only for historical data. New records always require a value for granted_by. """ - return sa.Column( + return sa.orm.mapped_column( sa.Integer, - sa.ForeignKey('user.id', ondelete='SET NULL'), + sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=cls.__null_granted_by__, ) - @with_roles(read={'subject', 'editor'}, grants={'editor'}) + @with_roles(read={'member', 'editor'}, grants={'editor'}) @declared_attr @classmethod - def granted_by(cls) -> Mapped[Optional[User]]: + def granted_by(cls) -> Mapped[Account | None]: """User who assigned the membership.""" - return sa.orm.relationship(User, foreign_keys=[cls.granted_by_id]) + return relationship(Account, foreign_keys=[cls.granted_by_id]) @hybrid_property def is_active(self) -> bool: @@ -197,46 +190,47 @@ def is_active(self) -> bool: and self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE ) - @is_active.expression - def is_active(cls): # noqa: N805 # pylint: disable=no-self-argument + @is_active.inplace.expression + @classmethod + def _is_active_expression(cls) -> sa.ColumnElement[bool]: """Test if membership record is active as a SQL expression.""" return sa.and_( cls.revoked_at.is_(None), cls.record_type != MEMBERSHIP_RECORD_TYPE.INVITE ) - with_roles(is_active, read={'subject'}) + with_roles(is_active, read={'member'}) @hybrid_property def is_invite(self) -> bool: """Test if membership record is an invitation.""" return self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE - with_roles(is_invite, read={'subject', 'editor'}) + with_roles(is_invite, read={'member', 'editor'}) @hybrid_property def is_amendment(self) -> bool: """Test if membership record is an amendment.""" return self.record_type == MEMBERSHIP_RECORD_TYPE.AMEND - with_roles(is_amendment, read={'subject', 'editor'}) + with_roles(is_amendment, read={'member', 'editor'}) def __repr__(self) -> str: # pylint: disable=using-constant-test return ( - f'<{self.__class__.__name__} {self.subject!r} in {self.parent!r} ' + f'<{self.__class__.__name__} {self.member!r} in {self.parent!r} ' + ('active' if self.is_active else 'revoked') + '>' ) @cached_property - def offered_roles(self) -> Set[str]: + def offered_roles(self) -> set[str]: """Return roles offered by this membership record.""" return set() # Subclasses must gate these methods in __roles__ - @with_roles(call={'subject', 'editor'}) - def revoke(self, actor: User) -> None: + @with_roles(call={'member', 'editor'}) + def revoke(self, actor: Account) -> None: """Revoke this membership record.""" if self.revoked_at is not None: raise MembershipRevokedError( @@ -251,7 +245,7 @@ def copy_template(self: MembershipType, **kwargs) -> MembershipType: @with_roles(call={'editor'}) def replace( - self: MembershipType, actor: User, _accept: bool = False, **data: Any + self: MembershipType, actor: Account, _accept: bool = False, **data: Any ) -> MembershipType: """Replace this membership record with changes to role columns.""" if self.revoked_at is not None: @@ -308,12 +302,12 @@ def replace( return new @with_roles(call={'editor'}) - def amend_by(self: MembershipType, actor: User): + def amend_by(self: MembershipType, actor: Account): """Amend a membership in a `with` context.""" return AmendMembership(self, actor) def merge_and_replace( - self: MembershipType, actor: User, other: MembershipType + self: MembershipType, actor: Account, other: MembershipType ) -> MembershipType: """Replace this record by merging data from an independent record.""" if self.__class__ is not other.__class__: @@ -349,21 +343,23 @@ def merge_and_replace( return replacement - @with_roles(call={'subject'}) - def accept(self: MembershipType, actor: User) -> MembershipType: + @with_roles(call={'member'}) + def accept(self: MembershipType, actor: Account) -> MembershipType: """Accept a membership invitation.""" if self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE: raise MembershipRecordTypeError("This membership record is not an invite") - if 'subject' not in self.roles_for(actor): + if 'member' not in self.roles_for(actor): raise ValueError("Invite must be accepted by the invited user") return self.replace(actor, _accept=True) - @with_roles(call={'owner', 'subject'}) - def freeze_subject_attribution(self: MembershipType, actor: User) -> MembershipType: + @with_roles(call={'owner', 'member'}) + def freeze_member_attribution( + self: MembershipType, actor: Account + ) -> MembershipType: """ - Freeze subject attribution and return a replacement record. + Freeze member attribution and return a replacement record. - Subclasses that support subject attribution must override this method. The + Subclasses that support member attribution must override this method. The default implementation returns `self`. """ return self @@ -375,27 +371,27 @@ class ImmutableUserMembershipMixin(ImmutableMembershipMixin): @declared_attr @classmethod - def user_id(cls) -> Mapped[int]: - """Foreign key column to user table.""" - return sa.Column( + def member_id(cls) -> Mapped[int]: + """Foreign key column to account table.""" + return sa.orm.mapped_column( sa.Integer, - sa.ForeignKey('user.id', ondelete='CASCADE'), + sa.ForeignKey('account.id', ondelete='CASCADE'), nullable=False, index=True, ) - @with_roles(read={'subject', 'editor'}, grants={'subject'}) + @with_roles(read={'member', 'editor'}, grants_via={None: {'admin': 'member'}}) @declared_attr @classmethod - def user(cls) -> Mapped[User]: - """User who is the subject of this membership record.""" - return sa.orm.relationship(User, foreign_keys=[cls.user_id]) + def member(cls) -> Mapped[Account]: + """Member in this membership record.""" + return relationship(Account, foreign_keys=[cls.member_id]) @declared_attr @classmethod - def subject(cls) -> Mapped[User]: - """Subject of this membership record.""" - return sa.orm.synonym('user') + def user(cls) -> Mapped[Account]: + """Legacy alias for member in this membership record.""" + return sa.orm.synonym('member') @declared_attr.directive @classmethod @@ -406,7 +402,7 @@ def __table_args__(cls) -> tuple: sa.Index( 'ix_' + cls.__tablename__ + '_active', cls.parent_id_column, - 'user_id', + 'member_id', unique=True, postgresql_where='revoked_at IS NULL', ), @@ -414,7 +410,7 @@ def __table_args__(cls) -> tuple: return ( sa.Index( 'ix_' + cls.__tablename__ + '_active', - 'user_id', + 'member_id', unique=True, postgresql_where='revoked_at IS NULL', ), @@ -422,185 +418,73 @@ def __table_args__(cls) -> tuple: @hybrid_property def is_self_granted(self) -> bool: - """Return True if the subject of this record is also the granting actor.""" - return self.user_id == self.granted_by_id - - with_roles(is_self_granted, read={'subject', 'editor'}) - - @hybrid_property - def is_self_revoked(self) -> bool: - """Return True if the subject of this record is also the revoking actor.""" - return self.user_id == self.revoked_by_id - - with_roles(is_self_revoked, read={'subject', 'editor'}) - - def copy_template(self: MembershipType, **kwargs) -> MembershipType: - return type(self)(user=self.user, **kwargs) # type: ignore - - @classmethod - def migrate_user( # type: ignore[return] - cls, old_user: User, new_user: User - ) -> OptionalMigratedTables: - """ - Migrate memberhip records from one user to another. - - If both users have active records, they are merged into a new record in the new - user's favour. All revoked records for the old user are transferred to the new - user. - """ - # Look up all active membership records of the subclass's type for the old user - # account. `cls` here represents the subclass. - old_user_records = cls.query.filter( - cls.user == old_user, cls.revoked_at.is_(None) - ).all() - # Look up all conflicting memberships for the new user account. Limit lookups by - # parent except when the membership type doesn't have a parent (SiteMembership). - if cls.parent_id is not None: - new_user_records = cls.query.filter( - cls.user == new_user, - cls.revoked_at.is_(None), - cls.parent_id.in_([r.parent_id for r in old_user_records]), - ).all() - else: - new_user_records = cls.query.filter( - cls.user == new_user, - cls.revoked_at.is_(None), - ).all() - new_user_records_by_parent = {r.parent_id: r for r in new_user_records} - - for record in old_user_records: - if record.parent_id in new_user_records_by_parent: - # Where there is a conflict, merge the records - new_user_records_by_parent[record.parent_id].merge_and_replace( - new_user, record - ) - db.session.flush() - - # Transfer all revoked records and non-conflicting active records. At this point - # no filter is necessary as the conflicting records have all been merged. - cls.query.filter(cls.user == old_user).update( - {'user_id': new_user.id}, synchronize_session=False - ) - # Also update the revoked_by and granted_by user accounts - cls.query.filter(cls.revoked_by == old_user).update( - {'revoked_by_id': new_user.id}, synchronize_session=False - ) - cls.query.filter(cls.granted_by == old_user).update( - {'granted_by_id': new_user.id}, synchronize_session=False + """Return True if the member in this record is also the granting actor.""" + return self.member_id == self.granted_by_id or ( + self.granted_by is not None and 'member' in self.roles_for(self.granted_by) ) - db.session.flush() - -@declarative_mixin -class ImmutableProfileMembershipMixin(ImmutableMembershipMixin): - """Support class for immutable memberships for accounts.""" - - @declared_attr - @classmethod - def profile_id(cls) -> Mapped[int]: - """Foreign key column to account (nee profile) table.""" - return sa.Column( - sa.Integer, - sa.ForeignKey('profile.id', ondelete='CASCADE'), - nullable=False, - index=True, - ) - - @with_roles(read={'subject', 'editor'}, grants_via={None: {'admin': 'subject'}}) - @declared_attr - @classmethod - def profile(cls) -> Mapped[Profile]: - """Account that is the subject of this membership record.""" - return sa.orm.relationship(Profile, foreign_keys=[cls.profile_id]) - - @declared_attr - @classmethod - def subject(cls) -> Mapped[Profile]: - """Subject of this membership record.""" - return sa.orm.synonym('profile') - - @declared_attr.directive - @classmethod - def __table_args__(cls) -> tuple: - if cls.parent_id is not None: - return ( - sa.Index( - 'ix_' + cls.__tablename__ + '_active', - cls.parent_id_column, - 'profile_id', - unique=True, - postgresql_where='revoked_at IS NULL', - ), - ) - return ( - sa.Index( - 'ix_' + cls.__tablename__ + '_active', - 'profile_id', - unique=True, - postgresql_where='revoked_at IS NULL', - ), - ) - - @hybrid_property - def is_self_granted(self) -> bool: - """Return True if the subject of this record is also the granting actor.""" - return 'subject' in self.roles_for(self.granted_by) - - with_roles(is_self_granted, read={'subject', 'editor'}) + with_roles(is_self_granted, read={'member', 'editor'}) @hybrid_property def is_self_revoked(self) -> bool: - """Return True if the subject of this record is also the revoking actor.""" - return 'subject' in self.roles_for(self.revoked_by) + """Return True if the member in this record is also the revoking actor.""" + return self.member_id == self.revoked_by_id or ( + self.revoked_by is not None and 'member' in self.roles_for(self.revoked_by) + ) - with_roles(is_self_revoked, read={'subject', 'editor'}) + with_roles(is_self_revoked, read={'member', 'editor'}) def copy_template(self: MembershipType, **kwargs) -> MembershipType: - return type(self)(profile=self.profile, **kwargs) # type: ignore + return type(self)(member=self.member, **kwargs) # type: ignore @classmethod - def migrate_profile( # type: ignore[return] - cls, old_profile: Profile, new_profile: Profile - ) -> OptionalMigratedTables: + def migrate_account(cls, old_account: Account, new_account: Account) -> None: """ - Migrate memberhip records from one account (nee profile) to another. + Migrate memberhip records from one account to another. If both accounts have active records, they are merged into a new record in the new account's favour. All revoked records for the old account are transferred to the new account. """ - # Look up all active membership records of the subclass's type for the old + # Look up all active membership records of the subclass's type for the old user # account. `cls` here represents the subclass. - old_profile_records = cls.query.filter( - cls.profile == old_profile, cls.revoked_at.is_(None) + old_account_records = cls.query.filter( + cls.member == old_account, cls.revoked_at.is_(None) ).all() # Look up all conflicting memberships for the new account. Limit lookups by - # parent except when the membership type doesn't have a parent. + # parent except when the membership type doesn't have a parent (SiteMembership). if cls.parent_id is not None: - new_profile_records = cls.query.filter( - cls.profile == new_profile, + new_account_records = cls.query.filter( + cls.member == new_account, cls.revoked_at.is_(None), - cls.parent_id.in_([r.parent_id for r in old_profile_records]), + cls.parent_id.in_([r.parent_id for r in old_account_records]), ).all() else: - new_profile_records = cls.query.filter( - cls.profile == new_profile, + new_account_records = cls.query.filter( + cls.member == new_account, cls.revoked_at.is_(None), ).all() - new_profile_records_by_parent = {r.parent_id: r for r in new_profile_records} + new_account_records_by_parent = {r.parent_id: r for r in new_account_records} - for record in old_profile_records: - if record.parent_id in new_profile_records_by_parent: + for record in old_account_records: + if record.parent_id in new_account_records_by_parent: # Where there is a conflict, merge the records - new_profile_records_by_parent[record.parent_id].merge_and_replace( - new_profile, record + new_account_records_by_parent[record.parent_id].merge_and_replace( + new_account, record ) db.session.flush() # Transfer all revoked records and non-conflicting active records. At this point # no filter is necessary as the conflicting records have all been merged. - cls.query.filter(cls.profile == old_profile).update( - {'profile_id': new_profile.id}, synchronize_session=False + cls.query.filter(cls.member == old_account).update( + {'member_id': new_account.id}, synchronize_session=False + ) + # Also update the revoked_by and granted_by accounts + cls.query.filter(cls.revoked_by == old_account).update( + {'revoked_by_id': new_account.id}, synchronize_session=False + ) + cls.query.filter(cls.granted_by == old_account).update( + {'granted_by_id': new_account.id}, synchronize_session=False ) db.session.flush() @@ -609,6 +493,9 @@ def migrate_profile( # type: ignore[return] class ReorderMembershipMixin(ReorderMixin): """Customizes ReorderMixin for membership models.""" + if TYPE_CHECKING: + parent_id_column: ClassVar[str] + #: Sequence number. Not immutable, and may be overwritten by ReorderMixin as a #: side-effect of reordering other records. This is not considered a revision. #: However, it can be argued that relocating a sponsor in the list constitutes a @@ -618,7 +505,7 @@ class ReorderMembershipMixin(ReorderMixin): @classmethod def seq(cls) -> Mapped[int]: """Ordering sequence number.""" - return sa.Column(sa.Integer, nullable=False) + return sa.orm.mapped_column(sa.Integer, nullable=False) @declared_attr.directive @classmethod @@ -640,8 +527,8 @@ def __table_args__(cls) -> tuple: def __init__(self, **kwargs) -> None: super().__init__(**kwargs) # Assign a default value to `seq` - if self.seq is None: - self.seq = ( + if self.seq is None: # Will be None until first commit + self.seq = ( # type: ignore[unreachable] sa.select(sa.func.coalesce(sa.func.max(self.__class__.seq) + 1, 1)) .where(self.parent_scoped_reorder_query_filter) .scalar_subquery() @@ -664,9 +551,9 @@ def parent_scoped_reorder_query_filter(self) -> ColumnElement: cls.parent_id == self.parent_id, cls.is_active, # type: ignore[attr-defined] ) - return sa.and_( - cls.parent == self.parent, # type: ignore[attr-defined] - cls.is_active, # type: ignore[attr-defined] + return sa.and_( # type: ignore[unreachable] + cls.parent == self.parent, + cls.is_active, ) @@ -674,16 +561,17 @@ def parent_scoped_reorder_query_filter(self) -> ColumnElement: class FrozenAttributionMixin: """Provides a `title` data column and support method to freeze it.""" - subject: SubjectType - replace: Callable[..., FrozenAttributionType] - _local_data_only: bool + if TYPE_CHECKING: + member: Mapped[Account] + replace: Callable[..., FrozenAttributionType] + _local_data_only: bool @declared_attr @classmethod - def _title(cls) -> Mapped[Optional[str]]: + def _title(cls) -> Mapped[str | None]: """Create optional attribution title for this membership record.""" return immutable( - sa.Column( + sa.orm.mapped_column( 'title', sa.Unicode, sa.CheckConstraint("title <> ''"), nullable=True ) ) @@ -693,31 +581,28 @@ def title(self) -> str: """Attribution title for this record.""" if self._local_data_only: return self._title # This may be None - return self._title or self.subject.title + return self._title or self.member.title @title.setter - def title(self, value: Optional[str]) -> None: + def title(self, value: str | None) -> None: """Set or clear custom attribution title.""" + # The title column is marked immutable, so this setter can only be called once, + # typically during __init__ self._title = value or None # Don't set empty string @property - def name(self): - """Return subject's name.""" - return self.subject.name - - @property - def pickername(self): - """Return subject's pickername.""" - return self.subject.pickername + def pickername(self) -> str: + """Return member's pickername, but only if attribution isn't frozen.""" + return self._title if self._title else self.member.pickername - @with_roles(call={'owner', 'subject'}) - def freeze_subject_attribution( - self: FrozenAttributionType, actor: User + @with_roles(call={'owner', 'member'}) + def freeze_member_attribution( + self: FrozenAttributionType, actor: Account ) -> FrozenAttributionType: - """Freeze subject attribution and return a replacement record.""" + """Freeze member attribution and return a replacement record.""" if self._title is None: membership: FrozenAttributionType = self.replace( - actor=actor, title=self.subject.title + actor=actor, title=self.member.title ) else: membership = self @@ -740,7 +625,7 @@ class AmendMembership(Generic[MembershipType]): to any attribute listed as a data column. """ - def __init__(self, membership: MembershipType, actor: User) -> None: + def __init__(self, membership: MembershipType, actor: Account) -> None: """Create an amendment placeholder.""" if membership.revoked_at is not None: raise MembershipRevokedError( @@ -779,27 +664,25 @@ def commit(self) -> MembershipType: return self.membership -@event.listens_for(EnumerateMembershipsMixin, 'mapper_configured', propagate=True) -def _confirm_enumerated_mixins(mapper, class_) -> None: +@event.listens_for(Account, 'mapper_configured', propagate=True) +def _confirm_enumerated_mixins(_mapper: Any, cls: type[Account]) -> None: """Confirm that the membership collection attributes actually exist.""" expected_class = ImmutableMembershipMixin - if issubclass(class_, User): + if issubclass(cls, Account): expected_class = ImmutableUserMembershipMixin - elif issubclass(class_, Profile): - expected_class = ImmutableProfileMembershipMixin for source in ( - class_.__active_membership_attrs__, - class_.__noninvite_membership_attrs__, + cls.__active_membership_attrs__, + cls.__noninvite_membership_attrs__, ): for attr_name in source: - relationship = getattr(class_, attr_name, None) - if relationship is None: + attr_relationship = getattr(cls, attr_name, None) + if attr_relationship is None: raise AttributeError( - f'{class_.__name__} does not have a relationship named' + f'{cls.__name__} does not have a relationship named' f' {attr_name!r} targeting a subclass of {expected_class.__name__}' ) - if not issubclass(relationship.property.mapper.class_, expected_class): + if not issubclass(attr_relationship.property.mapper.class_, expected_class): raise AttributeError( - f'{class_.__name__}.{attr_name} should be a relationship to a' + f'{cls.__name__}.{attr_name} should be a relationship to a' f' subclass of {expected_class.__name__}' ) diff --git a/funnel/models/moderation.py b/funnel/models/moderation.py index 12d78d1a5..0ba9f69e2 100644 --- a/funnel/models/moderation.py +++ b/funnel/models/moderation.py @@ -2,14 +2,15 @@ from __future__ import annotations -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - from baseframe import __ from coaster.sqlalchemy import StateManager, with_roles from coaster.utils import LabeledEnum -from . import BaseMixin, Comment, Mapped, SiteMembership, User, UuidMixin, db, sa +from . import BaseMixin, Mapped, Model, UuidMixin, backref, db, relationship, sa +from .account import Account +from .comment import Comment from .helpers import reopen +from .site_membership import SiteMembership __all__ = ['MODERATOR_REPORT_TYPE', 'CommentModeratorReport'] @@ -19,46 +20,43 @@ class MODERATOR_REPORT_TYPE(LabeledEnum): # noqa: N801 SPAM = (2, 'spam', __("Spam")) -class CommentModeratorReport( - UuidMixin, - BaseMixin, - db.Model, # type: ignore[name-defined] -): +class CommentModeratorReport(UuidMixin, BaseMixin, Model): __tablename__ = 'comment_moderator_report' - __allow_unmapped__ = True __uuid_primary_key__ = True - comment_id = sa.Column( + comment_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('comment.id'), nullable=False, index=True ) - comment: Mapped[Comment] = sa.orm.relationship( + comment: Mapped[Comment] = relationship( Comment, foreign_keys=[comment_id], - backref=sa.orm.backref('moderator_reports', cascade='all', lazy='dynamic'), + backref=backref('moderator_reports', cascade='all', lazy='dynamic'), ) - user_id = sa.Column( - sa.Integer, sa.ForeignKey('user.id'), nullable=False, index=True + reported_by_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False, index=True ) - user: Mapped[User] = sa.orm.relationship( - User, - foreign_keys=[user_id], - backref=sa.orm.backref('moderator_reports', cascade='all', lazy='dynamic'), + reported_by: Mapped[Account] = relationship( + Account, + foreign_keys=[reported_by_id], + backref=backref('moderator_reports', cascade='all', lazy='dynamic'), ) - report_type = sa.Column( + report_type = sa.orm.mapped_column( sa.SmallInteger, StateManager.check_constraint('report_type', MODERATOR_REPORT_TYPE), nullable=False, default=MODERATOR_REPORT_TYPE.SPAM, ) - reported_at = sa.Column( + reported_at = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), default=sa.func.utcnow(), nullable=False ) - resolved_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True, index=True) + resolved_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True, index=True + ) __datasets__ = { 'primary': { 'comment', - 'user', + 'reported_by', 'report_type', 'reported_at', 'resolved_at', @@ -84,7 +82,7 @@ def get_all(cls, exclude_user=None): # get all comment ids that the given user has already reviewed/reported existing_reported_comments = ( db.session.query(cls.comment_id) - .filter_by(user_id=exclude_user.id) + .filter_by(reported_by_id=exclude_user.id) .distinct() ) # exclude reports for those comments @@ -92,19 +90,21 @@ def get_all(cls, exclude_user=None): return reports @classmethod - def submit(cls, actor, comment): + def submit( + cls, actor: Account, comment: Comment + ) -> tuple[CommentModeratorReport, bool]: created = False - report = cls.query.filter_by(user=actor, comment=comment).one_or_none() + report = cls.query.filter_by(reported_by=actor, comment=comment).one_or_none() if report is None: - report = cls(user=actor, comment=comment) + report = cls(reported_by=actor, comment=comment) db.session.add(report) created = True return report, created @property def users_who_are_comment_moderators(self): - return User.query.join( - SiteMembership, SiteMembership.user_id == User.id + return Account.query.join( + SiteMembership, SiteMembership.member_id == Account.id ).filter( SiteMembership.is_active.is_(True), SiteMembership.is_comment_moderator.is_(True), @@ -115,13 +115,13 @@ def users_who_are_comment_moderators(self): @reopen(Comment) class __Comment: - def is_reviewed_by(self, user: User) -> bool: + def is_reviewed_by(self, account: Account) -> bool: return db.session.query( db.session.query(CommentModeratorReport) .filter( CommentModeratorReport.comment == self, CommentModeratorReport.resolved_at.is_(None), - CommentModeratorReport.user == user, + CommentModeratorReport.reported_by == account, ) .exists() ).scalar() diff --git a/funnel/models/notification.py b/funnel/models/notification.py index d2c92bc29..0abcb5a6c 100644 --- a/funnel/models/notification.py +++ b/funnel/models/notification.py @@ -4,14 +4,17 @@ Notification models and support classes for implementing notifications, best understood using examples: -Scenario: Project P's editor E posts an update U -Where: User A is a participant on the project -Result: User A receives a notification about a new update on the project +Scenario: Notification about an update + Given: User A is a participant in project P When: Project P's editor E posts an + update U Then: User A receives a notification about update U And: The notification + is attributed to editor E And: User A is informed of being a recipient for being a + participant in project P And: User A can choose to unsubscribe from notifications + about updates How it works: -1. View handler that creates the Update triggers an UpdateNotification on it. This is - a subclass of Notification. The UpdateNotification class specifies the roles that +1. The view handler that creates the Update triggers an UpdateNotification on it. This + is a subclass of Notification. The UpdateNotification class specifies the roles that must receive the notification. 2. Roles? Yes. UpdateNotification says it should be delivered to users possessing the @@ -21,85 +24,88 @@ such as in language: "the project you're a crew member of had an update", versus "the project you're a participant of had an update". -3. An UpdateNotification instance (a polymorphic class on top of Notification) is - created referring to the Update instance. It is then dispatched from the view by - calling the dispatch method on it, an iterator. This returns UserNotification - instances. - -4. To find users with the required roles, `Update.actors_for({roles})` is called. The - default implementation in RoleMixin is aware that these roles are inherited from - Project (using granted_via declarations), and so calls `Update.project.actors_for`. - -5. UserNotification.dispatch is now called from the view. User preferences are obtained - from the User model along with transport address (email, phone, etc). - -6. For each user in the filtered list, a UserNotification db instance is created. - -7. For notifications (not this one) where both a document and a fragment are present, - like ProposalReceivedNotication with Project+Proposal, a scan is performed for - previous unread instances of UserNotification referring to the same document, - determined from UserNotification.notification.document_uuid, and those are revoked - to remove them from the user's feed. A rollup is presented instead, showing all - freshly submitted proposals. - -8. A separate render view class named RenderNewUpdateNotification contains methods named +3. The view calls `dispatch_notification` with an instance of UpdateNotification + referring to the Update instance. The dispatcher can process multiple such + notifications at once, tagging them with a common eventid. It queues a background + worker in RQ to process the notifications. + +4. The background worker calls `UpdateNotification.dispatch` to find all recipients and + create `UserNotification` instances for each of them. The worker can be given + multiple notifications linked to the same event. If a user is identified as a + recipient to more than one notification, only the first match is used. To find + these recipients, the default notification-level dispatch method calls + `Update.actors_for({roles})`. The default implementation in RoleMixin is aware that + these roles are inherited from Project (using granted_via declarations), and so + it calls `Update.project.actors_for`. The obtained UserNotification instances are + batched and handed off to a second round of background workers. + +5. Each second background worker receives a batch of UserNotification instances and + discovers user preferences for the particular notification. Some notifications are + defined as being for a fragment of a larger document, like for an individual + comment in a large comment thread. In such a case, a scan is performed for previous + unread instances of UserNotification referring to the same document, determined + from `UserNotification.notification.document_uuid`, and those are revoked to remove + them from the user's feed. A rollup is presented instead, showing all fragments + since the last view or last day, whichever is greater. The second background worker + now queues a third series of background workers, for each of the supported + transports if at least one recipient in that batch wants to use that transport. + +6. A separate render view class named RenderNewUpdateNotification contains methods named like `web`, `email`, `sms` and others. These are expected to return a rendered message. The `web` render is used for the notification feed page on the website. -9. Views are registered to the model, so the dispatch mechanism only needs to call +7. Views are registered to the model, so the dispatch mechanism only needs to call ``view.email()`` etc to get the rendered content. The dispatch mechanism then calls the appropriate transport helper (``send_email``, etc) to do the actual sending. The - message id returned by these functions is saved to the messageid columns in - UserNotification, as record that the notification was sent. If the transport doesn't - support message ids, a random non-None value is used. Accurate message ids are only - required when user interaction over the same transport is expected, such as reply - emails. + message id returned by these functions is saved to the ``messageid_*`` columns in + UserNotification, as a record that the notification was sent. If the transport + doesn't support message ids, a random non-None value is used. Accurate message ids + are only required when user interaction over the same transport is expected, such + as reply emails. -10. The notifications endpoint on the website shows a feed of UserNotification items and - handles the ability to mark each as read. This marking is not yet automatically - performed in the links in the rendered templates that were sent out, but should be. +10. The ``/updates`` endpoint on the website shows a feed of UserNotification items and + handles the ability to mark each as read. It is possible to have two separate notifications for the same event. For example, a comment replying to another comment will trigger a CommentReplyNotification to the user being replied to, and a ProjectCommentNotification or ProposalCommentNotification for the project or proposal. The same user may be a recipient of both notifications. To de-duplicate this, a random "eventid" is shared across both notifications, and is -required to be unique per user, so that the second notification will be skipped. This -is supported using an unusual primary and foreign key structure the in -:class:`Notification` and :class:`UserNotification`: +required to be unique per user, so that the second notification will be skipped. This is +supported using an unusual primary and foreign key structure in :class:`Notification` +and :class:`UserNotification`: 1. Notification has pkey ``(eventid, id)``, where `id` is local to the instance -2. UserNotification has pkey ``(eventid, user_id)`` combined with a fkey to Notification - using ``(eventid, notification_id)``. +2. UserNotification has pkey ``(recipient_id, eventid)`` combined with a fkey to + Notification using ``(eventid, notification_id)`` """ from __future__ import annotations +from collections.abc import Callable, Generator, Sequence from dataclasses import dataclass +from datetime import datetime from types import SimpleNamespace from typing import ( Any, - Callable, ClassVar, - Dict, - Generator, + Generic, Optional, - Sequence, - Set, - Tuple, - Type, + TypeVar, Union, + cast, + get_args, + get_origin, ) +from typing_extensions import Protocol, get_original_bases from uuid import UUID, uuid4 from sqlalchemy import event from sqlalchemy.orm import column_keyed_dict from sqlalchemy.orm.exc import NoResultFound - from werkzeug.utils import cached_property from baseframe import __ from coaster.sqlalchemy import ( - Query, Registry, SqlUuidB58Comparator, auto_init_default, @@ -108,32 +114,56 @@ ) from coaster.utils import LabeledEnum, uuid_from_base58, uuid_to_base58 -from ..typing import OptionalMigratedTables, T, UuidModelType -from . import BaseMixin, Mapped, NoIdMixin, UUIDType, db, hybrid_property, sa +from ..typing import T +from . import ( + BaseMixin, + DynamicMapped, + Mapped, + Model, + NoIdMixin, + Query, + backref, + db, + hybrid_property, + postgresql, + relationship, + sa, +) +from .account import Account, AccountEmail, AccountPhone from .helpers import reopen from .phone_number import PhoneNumber, PhoneNumberMixin -from .user import User, UserEmail, UserPhone +from .typing import UuidModelUnion __all__ = [ 'SMS_STATUS', 'notification_categories', 'SmsMessage', + 'NotificationType', 'Notification', 'PreviewNotification', 'NotificationPreferences', - 'UserNotification', + 'NotificationRecipient', 'NotificationFor', 'notification_type_registry', 'notification_web_types', ] +# --- Typing --------------------------------------------------------------------------- + +# Document generic type +_D = TypeVar('_D', bound=UuidModelUnion) +# Fragment generic type +_F = TypeVar('_F', bound=Optional[UuidModelUnion]) +# Type of None (required to detect Optional) +NoneType = type(None) + # --- Registries ----------------------------------------------------------------------- #: Registry of Notification subclasses for user preferences, automatically populated. #: Inactive types and types that shadow other types are excluded from this registry -notification_type_registry: Dict[str, Notification] = {} +notification_type_registry: dict[str, type[Notification]] = {} #: Registry of notification types that allow web renders -notification_web_types: Set[Notification] = set() +notification_web_types: set[str] = set() @dataclass @@ -142,7 +172,7 @@ class NotificationCategory: priority_id: int title: str - available_for: Callable[[User], bool] + available_for: Callable[[Account], bool] #: Registry of notification categories @@ -204,25 +234,34 @@ class SMS_STATUS(LabeledEnum): # noqa: N801 # --- Legacy models -------------------------------------------------------------------- -class SmsMessage(PhoneNumberMixin, BaseMixin, db.Model): # type: ignore[name-defined] +class SmsMessage(PhoneNumberMixin, BaseMixin, Model): """An outbound SMS message.""" __tablename__ = 'sms_message' - __allow_unmapped__ = True __phone_optional__ = False __phone_unique__ = False __phone_is_exclusive__ = False phone_number_reference_is_active: bool = False - transactionid = immutable(sa.Column(sa.UnicodeText, unique=True, nullable=True)) + transactionid: Mapped[str | None] = immutable( + sa.orm.mapped_column(sa.UnicodeText, unique=True, nullable=True) + ) # The message itself - message = immutable(sa.Column(sa.UnicodeText, nullable=False)) + message: Mapped[str] = immutable( + sa.orm.mapped_column(sa.UnicodeText, nullable=False) + ) # Flags - status = sa.Column(sa.Integer, default=SMS_STATUS.QUEUED, nullable=False) - status_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) - fail_reason = sa.Column(sa.UnicodeText, nullable=True) + status: Mapped[int] = sa.orm.mapped_column( + sa.Integer, default=SMS_STATUS.QUEUED, nullable=False + ) + status_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + fail_reason: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: phone = kwargs.pop('phone', None) if phone: kwargs['phone_number'] = PhoneNumber.add(phone) @@ -232,7 +271,22 @@ def __init__(self, **kwargs): # --- Notification models -------------------------------------------------------------- -class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] +class NotificationType(Generic[_D, _F], Protocol): + """Protocol for :class:`Notification` and :class:`PreviewNotification`.""" + + type: str # noqa: A003 + eventid: UUID + id: UUID # noqa: A003 + eventid_b58: str + document: _D + document_uuid: UUID + fragment: _F | None + fragment_uuid: UUID | None + created_by_id: int | None + created_by: Account | None + + +class Notification(NoIdMixin, Model, Generic[_D, _F]): """ Holds a single notification for an activity on a document object. @@ -245,87 +299,88 @@ class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] """ __tablename__ = 'notification' - __allow_unmapped__ = True #: Flag indicating this is an active notification type. Can be False for draft #: and retired notification types to hide them from preferences UI - active = True + active: ClassVar[bool] = True #: Random identifier for the event that triggered this notification. Event ids can #: be shared across notifications, and will be used to enforce a limit of one #: instance of a UserNotification per-event rather than per-notification - eventid = immutable( - sa.Column( - UUIDType(binary=False), primary_key=True, nullable=False, default=uuid4 + eventid: Mapped[UUID] = immutable( + sa.orm.mapped_column( + postgresql.UUID, primary_key=True, nullable=False, default=uuid4 ) ) #: Notification id - id = immutable( # noqa: A003 - sa.Column( - UUIDType(binary=False), primary_key=True, nullable=False, default=uuid4 + id: Mapped[UUID] = immutable( # noqa: A003 + sa.orm.mapped_column( + postgresql.UUID, primary_key=True, nullable=False, default=uuid4 ) ) #: Default category of notification. Subclasses MUST override - category: NotificationCategory = notification_categories.none + category: ClassVar[NotificationCategory] = notification_categories.none #: Default description for notification. Subclasses MUST override - title: str = __("Unspecified notification type") + title: ClassVar[str] = __("Unspecified notification type") #: Default description for notification. Subclasses MUST override - description: str = '' + description: ClassVar[str] = '' #: Type of Notification subclass (auto-populated from subclass's `type=` parameter) - cls_type: str = '' + cls_type: ClassVar[str] = '' #: Type for user preferences, in case a notification type is a shadow of #: another type (auto-populated from subclass's `shadow=` parameter) - pref_type: str = '' + pref_type: ClassVar[str] = '' #: Document model, must be specified in subclasses - document_model: ClassVar[Type[UuidModelType]] + document_model: ClassVar[type[UuidModelUnion]] #: SQL table name for document type, auto-populated from the document model document_type: ClassVar[str] #: Fragment model, optional for subclasses - fragment_model: ClassVar[Optional[Type[UuidModelType]]] = None + fragment_model: ClassVar[type[UuidModelUnion] | None] = None #: SQL table name for fragment type, auto-populated from the fragment model - fragment_type: ClassVar[Optional[str]] + fragment_type: ClassVar[str | None] #: Roles to send notifications to. Roles must be in order of priority for situations #: where a user has more than one role on the document. - roles: Sequence[str] = [] + roles: ClassVar[Sequence[str]] = [] #: Exclude triggering actor from receiving notifications? Subclasses may override - exclude_actor = False + exclude_actor: ClassVar[bool] = False #: If this notification is typically for a single recipient, views will need to be #: careful about leaking out recipient identifiers such as a utm_source tracking tag - for_private_recipient = False + for_private_recipient: ClassVar[bool] = False #: The preference context this notification is being served under. Users may have #: customized preferences per account (nee profile) or project - preference_context: ClassVar[db.Model] = None # type: ignore[name-defined] + preference_context: ClassVar[Any] = None #: Notification type (identifier for subclass of :class:`NotificationType`) - type_: Mapped[str] = immutable(sa.Column('type', sa.Unicode, nullable=False)) + type_: Mapped[str] = immutable( + sa.orm.mapped_column('type', sa.Unicode, nullable=False) + ) #: Id of user that triggered this notification - user_id: Mapped[Optional[int]] = sa.Column( - sa.Integer, sa.ForeignKey('user.id', ondelete='SET NULL'), nullable=True + created_by_id: Mapped[int | None] = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=True ) #: User that triggered this notification. Optional, as not all notifications are #: caused by user activity. Used to optionally exclude user from receiving #: notifications of their own activity - user: Mapped[Optional[User]] = sa.orm.relationship(User) + created_by: Mapped[Account | None] = relationship(Account) #: UUID of document that the notification refers to document_uuid: Mapped[UUID] = immutable( - sa.Column(UUIDType(binary=False), nullable=False, index=True) + sa.orm.mapped_column(postgresql.UUID, nullable=False, index=True) ) #: Optional fragment within document that the notification refers to. This may be #: the document itself, or something within it, such as a comment. Notifications for #: multiple fragments are collapsed into a single notification - fragment_uuid: Mapped[Optional[UUID]] = immutable( - sa.Column(UUIDType(binary=False), nullable=True) + fragment_uuid: Mapped[UUID | None] = immutable( + sa.orm.mapped_column(postgresql.UUID, nullable=True) ) __table_args__ = ( @@ -340,7 +395,7 @@ class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] document_uuid, fragment_uuid, unique=True, - postgresql_where=fragment_uuid.isnot(None), + postgresql_where=fragment_uuid.is_not(None), ), ) @@ -384,42 +439,43 @@ class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] # for particular transports. #: This notification class may be seen on the website - allow_web = True + allow_web: ClassVar[bool] = True #: This notification class may be delivered by email - allow_email = True + allow_email: ClassVar[bool] = True #: This notification class may be delivered by SMS - allow_sms = True + allow_sms: ClassVar[bool] = True #: This notification class may be delivered by push notification - allow_webpush = True + allow_webpush: ClassVar[bool] = True #: This notification class may be delivered by Telegram message - allow_telegram = True + allow_telegram: ClassVar[bool] = True #: This notification class may be delivered by WhatsApp message - allow_whatsapp = True + allow_whatsapp: ClassVar[bool] = True # Flags to set defaults for transports, in case the user has not made a choice #: By default, turn on/off delivery by email - default_email = True + default_email: ClassVar[bool] = True #: By default, turn on/off delivery by SMS - default_sms = True + default_sms: ClassVar[bool] = True #: By default, turn on/off delivery by push notification - default_webpush = True + default_webpush: ClassVar[bool] = True #: By default, turn on/off delivery by Telegram message - default_telegram = True + default_telegram: ClassVar[bool] = True #: By default, turn on/off delivery by WhatsApp message - default_whatsapp = True + default_whatsapp: ClassVar[bool] = True #: Ignore transport errors? If True, an error will be ignored silently. If False, #: an error report will be logged for the user or site administrator. TODO - ignore_transport_errors = False + ignore_transport_errors: ClassVar[bool] = False #: Registry of per-class renderers ``{cls_type: CustomNotificationView}`` - renderers: Dict[str, Type] = {} # Can't import RenderNotification from views here + renderers: ClassVar[dict[str, type]] = {} + # Can't import RenderNotification from views here, so it's typed to just Type - def __init_subclass__( + def __init_subclass__( # pylint: disable=arguments-differ cls, type: str, # noqa: A002 # pylint: disable=redefined-builtin - shadows: Optional[Type[Notification]] = None, + shadows: type[Notification] | None = None, **kwargs, ) -> None: # For SQLAlchemy's polymorphic support @@ -427,6 +483,39 @@ def __init_subclass__( cls.__mapper_args__ = {} cls.__mapper_args__['polymorphic_identity'] = type + # Get document and fragment models from type hints + for base in get_original_bases(cls): + if get_origin(base) is Notification: + document_model, fragment_model = get_args(base) + if fragment_model is NoneType: + fragment_model = None + elif get_origin(fragment_model) is Optional: + fragment_model = get_args(fragment_model)[0] + elif get_origin(fragment_model) is Union: + _union_args = get_args(fragment_model) + if len(_union_args) == 2 and _union_args[1] is NoneType: + fragment_model = _union_args[0] + else: + raise TypeError( + f"Unsupported notification fragment: {fragment_model}" + ) + if 'document_model' in cls.__dict__: + if cls.document_model != document_model: + raise TypeError(f"{cls} has a conflicting document_model") + else: + cls.document_model = document_model + if 'fragment_model' in cls.__dict__: + if cls.fragment_model != fragment_model: + raise TypeError(f"{cls} has a conflicting fragment_model") + else: + cls.fragment_model = fragment_model + break + + cls.document_type = cls.document_model.__tablename__ + cls.fragment_type = ( + cls.fragment_model.__tablename__ if cls.fragment_model else None + ) + # For notification type identification and preference management cls.cls_type = type if shadows is not None: @@ -446,7 +535,12 @@ def __init_subclass__( return super().__init_subclass__(**kwargs) - def __init__(self, document=None, fragment=None, **kwargs) -> None: + def __init__( + self, + document: _D | None = None, + fragment: _F | None = None, + **kwargs: Any, + ) -> None: if document is not None: if not isinstance(document, self.document_model): raise TypeError(f"{document!r} is not of type {self.document_model!r}") @@ -454,13 +548,15 @@ def __init__(self, document=None, fragment=None, **kwargs) -> None: if fragment is not None: if self.fragment_model is None: raise TypeError(f"{self.__class__} is not expecting a fragment") - if not isinstance(fragment, self.fragment_model): # pylint: disable=W1116 + # Pylint can't parse the "is None" check above + # pylint: disable=isinstance-second-argument-not-valid-type + if not isinstance(fragment, self.fragment_model): raise TypeError(f"{fragment!r} is not of type {self.fragment_model!r}") kwargs['fragment_uuid'] = fragment.uuid super().__init__(**kwargs) @property - def identity(self) -> Tuple[UUID, UUID]: + def identity(self) -> tuple[UUID, UUID]: """Primary key of this object.""" return (self.eventid, self.id) @@ -469,30 +565,35 @@ def eventid_b58(self) -> str: """URL-friendly UUID representation, using Base58 with the Bitcoin alphabet.""" return uuid_to_base58(self.eventid) - @eventid_b58.setter - def eventid_b58(self, value: str) -> None: + @eventid_b58.inplace.setter + def _eventid_b58_setter(self, value: str) -> None: self.eventid = uuid_from_base58(value) - @eventid_b58.comparator - def eventid_b58(cls): # noqa: N805 # pylint: disable=no-self-argument + @eventid_b58.inplace.comparator + @classmethod + def _eventid_b58_comparator(cls) -> SqlUuidB58Comparator: """Return SQL comparator for Base58 rendering.""" return SqlUuidB58Comparator(cls.eventid) @cached_property - def document(self) -> Optional[UuidModelType]: + def document(self) -> _D: """ - Retrieve the document referenced by this Notification, if any. + Retrieve the document referenced by this Notification. This assumes the underlying object won't disappear, as there is no SQL foreign key constraint enforcing a link. The proper way to do this is by having a secondary table for each type of document. """ if self.document_uuid and self.document_model: - return self.document_model.query.filter_by(uuid=self.document_uuid).one() - return None + return cast( + _D, self.document_model.query.filter_by(uuid=self.document_uuid).one() + ) + raise RuntimeError( + "This notification is missing document_model or document_uuid" + ) @cached_property - def fragment(self) -> Optional[UuidModelType]: + def fragment(self) -> _F | None: """ Retrieve the fragment within a document referenced by this Notification, if any. @@ -500,11 +601,13 @@ def fragment(self) -> Optional[UuidModelType]: key constraint enforcing a link. """ if self.fragment_uuid and self.fragment_model: - return self.fragment_model.query.filter_by(uuid=self.fragment_uuid).one() + return cast( + _F, self.fragment_model.query.filter_by(uuid=self.fragment_uuid).one() + ) return None @classmethod - def renderer(cls, view: Type[T]) -> Type[T]: + def renderer(cls, view: type[T]) -> type[T]: """ Register a view class containing render methods. @@ -525,43 +628,45 @@ class MyNotificationView(NotificationView): return view @classmethod - def allow_transport(cls, transport) -> bool: + def allow_transport(cls, transport: str) -> bool: """Return ``cls.allow_``.""" return getattr(cls, 'allow_' + transport) @property - def role_provider_obj(self): + def role_provider_obj(self) -> _F | _D: """Return fragment if exists, document otherwise, indicating role provider.""" - return self.fragment or self.document + return cast(Union[_F, _D], self.fragment or self.document) - def dispatch(self) -> Generator[UserNotification, None, None]: + def dispatch(self) -> Generator[NotificationRecipient, None, None]: """ Create :class:`UserNotification` instances and yield in an iterator. - This is a heavy method and must be called from a background job. When making - new notifications, it will revoke previous notifications issued against the - same document. + This is a heavy method and must be called from a background job. It creates + instances of :class:`UserNotification` for each discovered recipient and yields + them, skipping over pre-existing instances (typically caused by a second + dispatch on the same event, such as when a background job fails midway and is + restarted). Subclasses wanting more control over how their notifications are dispatched should override this method. """ - for user, role in self.role_provider_obj.actors_with( + for account, role in self.role_provider_obj.actors_with( self.roles, with_role=True ): # If this notification requires that it not be sent to the actor that - # triggered the notification, don't notify them. For example, a user - # who leaves a comment should not be notified of their own comment. - # This `if` condition uses `user_id` instead of the recommended `user` - # for faster processing in a loop. + # triggered the notification, don't notify them. For example, a user who + # leaves a comment should not be notified of their own comment. This `if` + # condition uses `created_by_id` instead of the recommended `created_by` for + # faster processing in a loop. if ( self.exclude_actor - and self.user_id is not None - and self.user_id == user.id + and self.created_by_id is not None + and self.created_by_id == account.id ): continue # Don't notify inactive (suspended, merged) users - if not user.state.ACTIVE: + if not account.state.ACTIVE: continue # Was a notification already sent to this user? If so: @@ -572,16 +677,18 @@ def dispatch(self) -> Generator[UserNotification, None, None]: # Since this query uses SQLAlchemy's session cache, we don't have to # bother with a local cache for the first case. - existing_notification = UserNotification.query.get((user.id, self.eventid)) + existing_notification = NotificationRecipient.query.get( + (account.id, self.eventid) + ) if existing_notification is None: - user_notification = UserNotification( + recipient = NotificationRecipient( eventid=self.eventid, - user_id=user.id, + recipient_id=account.id, notification_id=self.id, role=role, ) - db.session.add(user_notification) - yield user_notification + db.session.add(recipient) + yield recipient # 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 @@ -589,38 +696,46 @@ def dispatch(self) -> Generator[UserNotification, None, None]: type: Mapped[str] = sa.orm.synonym('type_') # noqa: A003 -class PreviewNotification: +class PreviewNotification(NotificationType): """ Mimics a Notification subclass without instantiating it, for providing a preview. To be used with :class:`NotificationFor`:: - NotificationFor(PreviewNotification(NotificationType), user) + NotificationFor( + PreviewNotification(NotificationType, document, fragment, actor), + recipient + ) """ - def __init__( + def __init__( # pylint: disable=super-init-not-called self, - cls: Type[Notification], - document: UuidModelType, - fragment: Optional[UuidModelType] = None, + cls: type[Notification], + document: UuidModelUnion, + fragment: UuidModelUnion | None = None, + user: Account | None = None, ) -> None: - self.eventid = self.eventid_b58 = self.id = 'preview' # May need to be a UUID + self.eventid = uuid4() + self.id = uuid4() + self.eventid_b58 = uuid_to_base58(self.eventid) self.cls = cls self.type = cls.cls_type self.document = document self.document_uuid = document.uuid self.fragment = fragment self.fragment_uuid = fragment.uuid if fragment is not None else None + self.created_by = user + self.created_by_id = cast(int, user.id) if user is not None else None def __getattr__(self, attr: str) -> Any: """Get an attribute.""" return getattr(self.cls, attr) -class UserNotificationMixin: - """Shared mixin for :class:`UserNotification` and :class:`NotificationFor`.""" +class NotificationRecipientMixin: + """Shared mixin for :class:`NotificationRecipient` and :class:`NotificationFor`.""" - notification: Union[Notification, PreviewNotification] + notification: Mapped[Notification] | Notification | PreviewNotification @cached_property def notification_type(self) -> str: @@ -640,29 +755,20 @@ def notification_pref_type(self) -> str: with_roles(notification_pref_type, read={'owner'}) @cached_property - def document(self) -> Optional[db.Model]: # type: ignore[name-defined] + def document(self) -> UuidModelUnion | None: """Document that this notification is for.""" return self.notification.document with_roles(document, read={'owner'}) @cached_property - def fragment(self) -> Optional[db.Model]: # type: ignore[name-defined] + def fragment(self) -> UuidModelUnion | None: """Fragment within this document that this notification is for.""" return self.notification.fragment with_roles(fragment, read={'owner'}) - # This dummy property is required because of a pending mypy issue: - # https://github.com/python/mypy/issues/4125 - @property - def is_revoked(self) -> bool: - """Test if notification has been revoked.""" - raise NotImplementedError("Subclass must provide this property") - - @is_revoked.setter - def is_revoked(self, value: bool) -> None: - raise NotImplementedError("Subclass must provide this property") + is_revoked: bool def is_not_deleted(self, revoke: bool = False) -> bool: """ @@ -681,51 +787,48 @@ def is_not_deleted(self, revoke: bool = False) -> bool: return False -class UserNotification( - UserNotificationMixin, - NoIdMixin, - db.Model, # type: ignore[name-defined] -): +class NotificationRecipient(NotificationRecipientMixin, NoIdMixin, Model): """ The recipient of a notification. Contains delivery metadata and helper methods to render the notification. """ - __tablename__ = 'user_notification' - __allow_unmapped__ = True + __tablename__ = 'notification_recipient' - # Primary key is a compound of (user_id, eventid). + # Primary key is a compound of (recipient_id, eventid). #: Id of user being notified - user_id: Mapped[int] = immutable( - sa.Column( + recipient_id: Mapped[int] = immutable( + sa.orm.mapped_column( sa.Integer, - sa.ForeignKey('user.id', ondelete='CASCADE'), + sa.ForeignKey('account.id', ondelete='CASCADE'), primary_key=True, nullable=False, ) ) #: User being notified (backref defined below, outside the model) - user: Mapped[User] = with_roles( - sa.orm.relationship(User), read={'owner'}, grants={'owner'} + recipient: Mapped[Account] = with_roles( + relationship(Account), read={'owner'}, grants={'owner'} ) #: Random eventid, shared with the Notification instance eventid: Mapped[UUID] = with_roles( - immutable(sa.Column(UUIDType(binary=False), primary_key=True, nullable=False)), + immutable( + sa.orm.mapped_column(postgresql.UUID, primary_key=True, nullable=False) + ), read={'owner'}, ) #: Id of notification that this user received (fkey in __table_args__ below) - notification_id: Mapped[UUID] = sa.Column(UUIDType(binary=False), nullable=False) + notification_id: Mapped[UUID] = sa.orm.mapped_column( + postgresql.UUID, nullable=False + ) #: Notification that this user received - notification = with_roles( - sa.orm.relationship( - Notification, backref=sa.orm.backref('recipients', lazy='dynamic') - ), + notification: Mapped[Notification] = with_roles( + relationship(Notification, backref=backref('recipients', lazy='dynamic')), read={'owner'}, ) @@ -735,11 +838,13 @@ class UserNotification( #: Note: This column represents the first instance of a role shifting from being an #: entirely in-app symbol (i.e., code refactorable) to being data in the database #: (i.e., requiring a data migration alongside a code refactor) - role = with_roles(immutable(sa.Column(sa.Unicode, nullable=False)), read={'owner'}) + role: Mapped[str] = with_roles( + immutable(sa.orm.mapped_column(sa.Unicode, nullable=False)), read={'owner'} + ) #: Timestamp for when this notification was marked as read - read_at = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), default=None, nullable=True), + read_at: Mapped[datetime | None] = with_roles( + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), default=None, nullable=True), read={'owner'}, ) @@ -748,33 +853,42 @@ class UserNotification( #: 2. A new notification has been raised for the same document and this user was #: a recipient of the new notification #: 3. The underlying document or fragment has been deleted - revoked_at = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), + revoked_at: Mapped[datetime | None] = with_roles( + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'owner'}, ) #: When a roll-up is performed, record an identifier for the items rolled up - rollupid = with_roles( - sa.Column(UUIDType(binary=False), nullable=True, index=True), read={'owner'} + rollupid: Mapped[UUID | None] = with_roles( + sa.orm.mapped_column(postgresql.UUID, nullable=True, index=True), + read={'owner'}, ) #: Message id for email delivery - messageid_email = sa.Column(sa.Unicode, nullable=True) + messageid_email: Mapped[str | None] = sa.orm.mapped_column( + sa.Unicode, nullable=True + ) #: Message id for SMS delivery - messageid_sms = sa.Column(sa.Unicode, nullable=True) + messageid_sms: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode, nullable=True) #: Message id for web push delivery - messageid_webpush = sa.Column(sa.Unicode, nullable=True) + messageid_webpush: Mapped[str | None] = sa.orm.mapped_column( + sa.Unicode, nullable=True + ) #: Message id for Telegram delivery - messageid_telegram = sa.Column(sa.Unicode, nullable=True) + messageid_telegram: Mapped[str | None] = sa.orm.mapped_column( + sa.Unicode, nullable=True + ) #: Message id for WhatsApp delivery - messageid_whatsapp = sa.Column(sa.Unicode, nullable=True) + messageid_whatsapp: Mapped[str | None] = sa.orm.mapped_column( + sa.Unicode, nullable=True + ) __table_args__ = ( sa.ForeignKeyConstraint( - [eventid, notification_id], # type: ignore[list-item] + [eventid, notification_id], [Notification.eventid, Notification.id], ondelete='CASCADE', - name='user_notification_eventid_notification_id_fkey', + name='notification_recipient_eventid_notification_id_fkey', ), ) @@ -807,21 +921,22 @@ class UserNotification( # --- User notification properties ------------------------------------------------- @property - def identity(self) -> Tuple[int, UUID]: + def identity(self) -> tuple[int, UUID]: """Primary key of this object.""" - return (self.user_id, self.eventid) + return (self.recipient_id, self.eventid) @hybrid_property def eventid_b58(self) -> str: """URL-friendly UUID representation, using Base58 with the Bitcoin alphabet.""" return uuid_to_base58(self.eventid) - @eventid_b58.setter - def eventid_b58(self, value: str): + @eventid_b58.inplace.setter + def _eventid_b58_setter(self, value: str) -> None: self.eventid = uuid_from_base58(value) - @eventid_b58.comparator - def eventid_b58(cls): # noqa: N805 # pylint: disable=no-self-argument + @eventid_b58.inplace.comparator + @classmethod + def _eventid_b58_comparator(cls) -> SqlUuidB58Comparator: """Return SQL comparator for Base58 representation.""" return SqlUuidB58Comparator(cls.eventid) @@ -832,55 +947,55 @@ def is_read(self) -> bool: """Whether this notification has been marked as read.""" return self.read_at is not None - @is_read.setter - def is_read(self, value: bool) -> None: + @is_read.inplace.setter + def _is_read_setter(self, value: bool) -> None: if value: if not self.read_at: self.read_at = sa.func.utcnow() else: self.read_at = None - @is_read.expression - def is_read(cls): # noqa: N805 # pylint: disable=no-self-argument + @is_read.inplace.expression + @classmethod + def _is_read_expression(cls) -> sa.ColumnElement[bool]: """Test if notification has been marked as read, as a SQL expression.""" - return cls.read_at.isnot(None) + return cls.read_at.is_not(None) with_roles(is_read, rw={'owner'}) @hybrid_property - def is_revoked(self) -> bool: # pylint: disable=invalid-overridden-method + def is_revoked(self) -> bool: # type: ignore[override] """Whether this notification has been marked as revoked.""" return self.revoked_at is not None - @is_revoked.setter - def is_revoked(self, value: bool) -> None: + @is_revoked.inplace.setter + def _is_revoked_setter(self, value: bool) -> None: + """Set or remove revoked_at timestamp.""" if value: if not self.revoked_at: self.revoked_at = sa.func.utcnow() else: self.revoked_at = None - # PyLint complains because the hybrid property doesn't resemble the mixin's property - # pylint: disable=no-self-argument,arguments-renamed,invalid-overridden-method - @is_revoked.expression - def is_revoked(cls): # noqa: N805 - return cls.revoked_at.isnot(None) - - # pylint: enable=no-self-argument,arguments-renamed,invalid-overridden-method + @is_revoked.inplace.expression + @classmethod + def _is_revoked_expression(cls) -> sa.ColumnElement[bool]: + """Return SQL Expression.""" + return cls.revoked_at.is_not(None) with_roles(is_revoked, rw={'owner'}) # --- Dispatch helper methods ------------------------------------------------------ - def user_preferences(self) -> NotificationPreferences: - """Return the user's notification preferences for this notification type.""" - prefs = self.user.notification_preferences.get(self.notification_pref_type) + def recipient_preferences(self) -> NotificationPreferences: + """Return the account's notification preferences for this notification type.""" + prefs = self.recipient.notification_preferences.get(self.notification_pref_type) if prefs is None: prefs = NotificationPreferences( - notification_type=self.notification_pref_type, user=self.user + notification_type=self.notification_pref_type, account=self.recipient ) db.session.add(prefs) - self.user.notification_preferences[self.notification_pref_type] = prefs + self.recipient.notification_preferences[self.notification_pref_type] = prefs return prefs def has_transport(self, transport: str) -> bool: @@ -897,16 +1012,16 @@ def has_transport(self, transport: str) -> bool: # This property inserts the row if not already present. An immediate database # commit is required to ensure a parallel worker processing another notification # doesn't make a conflicting row. - main_prefs = self.user.main_notification_preferences - user_prefs = self.user_preferences() + main_prefs = self.recipient.main_notification_preferences + user_prefs = self.recipient_preferences() return ( self.notification.allow_transport(transport) and main_prefs.by_transport(transport) and user_prefs.by_transport(transport) - and self.user.has_transport(transport) + and self.recipient.has_transport(transport) ) - def transport_for(self, transport: str) -> Optional[Union[UserEmail, UserPhone]]: + def transport_for(self, transport: str) -> AccountEmail | AccountPhone | None: """ Return transport address for the requested transport. @@ -917,14 +1032,14 @@ def transport_for(self, transport: str) -> Optional[Union[UserEmail, UserPhone]] 3. The user's per-type preference allows it 4. The user has this transport (verified email or phone, etc) """ - main_prefs = self.user.main_notification_preferences - user_prefs = self.user_preferences() + main_prefs = self.recipient.main_notification_preferences + user_prefs = self.recipient_preferences() if ( self.notification.allow_transport(transport) and main_prefs.by_transport(transport) and user_prefs.by_transport(transport) ): - return self.user.transport_for( + return self.recipient.transport_for( transport, self.notification.preference_context ) return None @@ -949,30 +1064,31 @@ def rollup_previous(self) -> None: # the latest in that batch of rolled up notifications. If none, this is the # start of a new batch, so make a new id. rollupid = ( - db.session.query(UserNotification.rollupid) + db.session.query(NotificationRecipient.rollupid) .join(Notification) .filter( # Same user - UserNotification.user_id == self.user_id, + NotificationRecipient.recipient_id == self.recipient_id, # Same type of notification Notification.type == self.notification.type, # Same document Notification.document_uuid == self.notification.document_uuid, # Same reason for receiving notification as earlier instance (same role) - UserNotification.role == self.role, + NotificationRecipient.role == self.role, # Earlier instance is unread or within 24 hours sa.or_( - UserNotification.read_at.is_(None), + NotificationRecipient.read_at.is_(None), # TODO: Hardcodes for PostgreSQL, turn this into a SQL func # expression like func.utcnow() - UserNotification.created_at >= sa.text("NOW() - INTERVAL '1 DAY'"), + NotificationRecipient.created_at + >= sa.text("NOW() - INTERVAL '1 DAY'"), ), # Earlier instance is not revoked - UserNotification.revoked_at.is_(None), + NotificationRecipient.revoked_at.is_(None), # Earlier instance has a rollupid - UserNotification.rollupid.isnot(None), + NotificationRecipient.rollupid.is_not(None), ) - .order_by(UserNotification.created_at.asc()) + .order_by(NotificationRecipient.created_at.asc()) .limit(1) .scalar() ) @@ -987,36 +1103,36 @@ def rollup_previous(self) -> None: # Now rollup all previous unread. This will skip (a) previously revoked user # notifications, and (b) unrolled but read user notifications. for previous in ( - UserNotification.query.join(Notification) + NotificationRecipient.query.join(Notification) .filter( # Same user - UserNotification.user_id == self.user_id, + NotificationRecipient.recipient_id == self.recipient_id, # Not ourselves - UserNotification.eventid != self.eventid, + NotificationRecipient.eventid != self.eventid, # Same type of notification Notification.type == self.notification.type, # Same document Notification.document_uuid == self.notification.document_uuid, # Same role as earlier notification, - UserNotification.role == self.role, + NotificationRecipient.role == self.role, # Earlier instance is not revoked - UserNotification.revoked_at.is_(None), + NotificationRecipient.revoked_at.is_(None), # Earlier instance shares our rollupid - UserNotification.rollupid == self.rollupid, + NotificationRecipient.rollupid == self.rollupid, ) .options( sa.orm.load_only( - UserNotification.user_id, - UserNotification.eventid, - UserNotification.revoked_at, - UserNotification.rollupid, + NotificationRecipient.recipient_id, + NotificationRecipient.eventid, + NotificationRecipient.revoked_at, + NotificationRecipient.rollupid, ) ) ): previous.is_revoked = True previous.rollupid = self.rollupid - def rolledup_fragments(self) -> Optional[Query]: + def rolledup_fragments(self) -> Query | None: """Return all fragments in the rolled up batch as a base query.""" if not self.notification.fragment_model: return None @@ -1028,63 +1144,66 @@ def rolledup_fragments(self) -> Optional[Query]: return self.notification.fragment_model.query.filter( self.notification.fragment_model.uuid.in_( db.session.query(Notification.fragment_uuid) - .select_from(UserNotification) - .join(UserNotification.notification) - .filter(UserNotification.rollupid == self.rollupid) + .select_from(NotificationRecipient) + .join(NotificationRecipient.notification) + .filter(NotificationRecipient.rollupid == self.rollupid) ) ) @classmethod - def get_for(cls, user: User, eventid_b58: str) -> Optional[UserNotification]: + def get_for(cls, user: Account, eventid_b58: str) -> NotificationRecipient | None: """Retrieve a :class:`UserNotification` using SQLAlchemy session cache.""" return cls.query.get((user.id, uuid_from_base58(eventid_b58))) @classmethod - def web_notifications_for(cls, user: User, unread_only: bool = False) -> Query: + def web_notifications_for( + cls, user: Account, unread_only: bool = False + ) -> Query[NotificationRecipient]: """Return web notifications for a user, optionally returning unread-only.""" - query = UserNotification.query.join(Notification).filter( + query = NotificationRecipient.query.join(Notification).filter( Notification.type.in_(notification_web_types), - UserNotification.user == user, - UserNotification.revoked_at.is_(None), + NotificationRecipient.recipient == user, + NotificationRecipient.revoked_at.is_(None), ) if unread_only: - query = query.filter(UserNotification.read_at.is_(None)) + query = query.filter(NotificationRecipient.read_at.is_(None)) return query.order_by(Notification.created_at.desc()) @classmethod - def unread_count_for(cls, user: User) -> int: + def unread_count_for(cls, user: Account) -> int: """Return unread notification count for a user.""" return ( - UserNotification.query.join(Notification) + NotificationRecipient.query.join(Notification) .filter( Notification.type.in_(notification_web_types), - UserNotification.user == user, - UserNotification.read_at.is_(None), - UserNotification.revoked_at.is_(None), + NotificationRecipient.recipient == user, + NotificationRecipient.read_at.is_(None), + NotificationRecipient.revoked_at.is_(None), ) .count() ) @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 user_notification in cls.query.filter_by(user_id=old_user.id).all(): - existing = cls.query.get((new_user.id, user_notification.eventid)) + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" + for notification_recipient in cls.query.filter_by( + recipient_id=old_account.id + ).all(): + existing = cls.query.get((new_account.id, notification_recipient.eventid)) # TODO: Instead of dropping old_user's dupe notifications, check which of # the two has a higher priority role and keep that. This may not be possible # if the two copies are for different notifications under the same eventid. if existing is not None: - db.session.delete(user_notification) - cls.query.filter_by(user_id=old_user.id).update( - {'user_id': new_user.id}, synchronize_session=False + db.session.delete(notification_recipient) + cls.query.filter(cls.recipient_id == old_account.id).update( + {'recipient_id': new_account.id}, synchronize_session=False ) -class NotificationFor(UserNotificationMixin): +class NotificationFor(NotificationRecipientMixin): """View-only wrapper to mimic :class:`UserNotification`.""" + notification: Notification | PreviewNotification identity: Any = None read_at: Any = None revoked_at: Any = None @@ -1094,26 +1213,26 @@ class NotificationFor(UserNotificationMixin): views = Registry() def __init__( - self, notification: Union[Notification, PreviewNotification], user: User + self, notification: Notification | PreviewNotification, recipient: Account ) -> None: self.notification = notification self.eventid = notification.eventid self.notification_id = notification.id - self.user = user - self.user_id = user.id + self.recipient = recipient + self.recipient_id = recipient.id @property - def role(self) -> Optional[str]: + def role(self) -> str | None: """User's primary matching role for this notification.""" - if self.document and self.user: - roles = self.document.roles_for(self.user) + if self.document and self.recipient: + roles = self.document.roles_for(self.recipient) for role in self.notification.roles: if role in roles: return role return None - def rolledup_fragments(self) -> Optional[Query]: + def rolledup_fragments(self) -> Query | None: """Return a query to load the notification fragment.""" if not self.notification.fragment_model: return None @@ -1125,37 +1244,48 @@ def rolledup_fragments(self) -> Optional[Query]: # --- Notification preferences --------------------------------------------------------- -class NotificationPreferences(BaseMixin, db.Model): # type: ignore[name-defined] +class NotificationPreferences(BaseMixin, Model): """Holds a user's preferences for a particular :class:`Notification` type.""" __tablename__ = 'notification_preferences' - __allow_unmapped__ = True - #: Id of user whose preferences are represented here - user_id = sa.Column( + #: Id of account whose preferences are represented here + account_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, - sa.ForeignKey('user.id', ondelete='CASCADE'), + sa.ForeignKey('account.id', ondelete='CASCADE'), nullable=False, index=True, ) - #: User whose preferences are represented here - user = with_roles( - sa.orm.relationship(User, back_populates='notification_preferences'), + #: User account whose preferences are represented here + account = with_roles( + relationship(Account, back_populates='notification_preferences'), read={'owner'}, grants={'owner'}, ) # Notification type, corresponding to Notification.type (a class attribute there) # notification_type = '' holds the veto switch to disable a transport entirely - notification_type = immutable(sa.Column(sa.Unicode, nullable=False)) + notification_type: Mapped[str] = immutable( + sa.orm.mapped_column(sa.Unicode, nullable=False) + ) - by_email = with_roles(sa.Column(sa.Boolean, nullable=False), rw={'owner'}) - by_sms = with_roles(sa.Column(sa.Boolean, nullable=False), rw={'owner'}) - by_webpush = with_roles(sa.Column(sa.Boolean, nullable=False), rw={'owner'}) - by_telegram = with_roles(sa.Column(sa.Boolean, nullable=False), rw={'owner'}) - by_whatsapp = with_roles(sa.Column(sa.Boolean, nullable=False), rw={'owner'}) + by_email: Mapped[bool] = with_roles( + sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + ) + by_sms: Mapped[bool] = with_roles( + sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + ) + by_webpush: Mapped[bool] = with_roles( + sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + ) + by_telegram: Mapped[bool] = with_roles( + sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + ) + by_whatsapp: Mapped[bool] = with_roles( + sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + ) - __table_args__ = (sa.UniqueConstraint('user_id', 'notification_type'),) + __table_args__ = (sa.UniqueConstraint('account_id', 'notification_type'),) __datasets__ = { 'preferences': { @@ -1169,14 +1299,14 @@ class NotificationPreferences(BaseMixin, db.Model): # type: ignore[name-defined def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - if self.user: + if self.account: self.set_defaults() def __repr__(self) -> str: """Represent :class:`NotificationPreferences` as a string.""" return ( f'NotificationPreferences(' - f'notification_type={self.notification_type!r}, user={self.user!r}' + f'notification_type={self.notification_type!r}, account={self.account!r}' f')' ) @@ -1190,7 +1320,7 @@ def set_defaults(self) -> None: ('by_whatsapp', 'default_whatsapp'), ) with db.session.no_autoflush: - if not self.user.notification_preferences: + if not self.account.notification_preferences: # No existing preferences. Get defaults from notification type's class if ( self.notification_type @@ -1215,7 +1345,7 @@ def set_defaults(self) -> None: t_attr, any( getattr(np, t_attr) - for np in self.user.notification_preferences.values() + for np in self.account.notification_preferences.values() ), ) @@ -1230,7 +1360,7 @@ def set_transport(self, transport: str, value: bool) -> None: setattr(self, 'by_' + transport, value) @cached_property - def type_cls(self) -> Optional[Notification]: + def type_cls(self) -> type[Notification] | None: """Return the Notification subclass corresponding to self.notification_type.""" # Use `registry.get(type)` instead of `registry[type]` because the user may have # saved preferences for a discontinued notification type. These should ideally @@ -1238,19 +1368,17 @@ def type_cls(self) -> Optional[Notification]: return notification_type_registry.get(self.notification_type) @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 ntype, prefs in list(old_user.notification_preferences.items()): - if ntype in new_user.notification_preferences: + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" + for ntype, prefs in list(old_account.notification_preferences.items()): + if ntype in new_account.notification_preferences: db.session.delete(prefs) - NotificationPreferences.query.filter_by(user_id=old_user.id).update( - {'user_id': new_user.id}, synchronize_session=False + cls.query.filter(cls.account_id == old_account.id).update( + {'account_id': new_account.id}, synchronize_session=False ) @sa.orm.validates('notification_type') - def _valid_notification_type(self, key: str, value: Optional[str]) -> str: + def _valid_notification_type(self, key: str, value: str | None) -> str: if value == '': # Special-cased name for main preferences return value if value is None or value not in notification_type_registry: @@ -1258,29 +1386,29 @@ def _valid_notification_type(self, key: str, value: Optional[str]) -> str: return value -@reopen(User) -class __User: - all_notifications = with_roles( - sa.orm.relationship( - UserNotification, +@reopen(Account) +class __Account: + all_notifications: DynamicMapped[NotificationRecipient] = with_roles( + relationship( + NotificationRecipient, lazy='dynamic', - order_by=UserNotification.created_at.desc(), + order_by=NotificationRecipient.created_at.desc(), viewonly=True, ), read={'owner'}, ) - notification_preferences = sa.orm.relationship( + notification_preferences: Mapped[dict[str, NotificationPreferences]] = relationship( NotificationPreferences, collection_class=column_keyed_dict(NotificationPreferences.notification_type), - back_populates='user', + back_populates='account', ) # This relationship is wrapped in a property that creates it on first access - _main_notification_preferences = sa.orm.relationship( + _main_notification_preferences: Mapped[NotificationPreferences] = relationship( NotificationPreferences, primaryjoin=sa.and_( - NotificationPreferences.user_id == User.id, + NotificationPreferences.account_id == Account.id, NotificationPreferences.notification_type == '', ), uselist=False, @@ -1293,7 +1421,7 @@ def main_notification_preferences(self) -> NotificationPreferences: if not self._main_notification_preferences: main = NotificationPreferences( notification_type='', - user=self, + account=self, by_email=True, by_sms=True, by_webpush=False, @@ -1312,28 +1440,15 @@ def main_notification_preferences(self) -> NotificationPreferences: @event.listens_for(Notification, 'mapper_configured', propagate=True) -def _register_notification_types(mapper_, cls) -> None: +def _register_notification_types(mapper_: Any, cls: type[Notification]) -> None: # Don't register the base class itself, or inactive types if cls is not Notification: - # Tell mypy what type of class we're processing - assert issubclass(cls, Notification) # nosec - - # Populate cls with helper attributes + # Add the subclass to the registry if cls.document_model is None: raise TypeError( f"Notification subclass {cls!r} must specify document_model" ) - cls.document_type = ( - cls.document_model.__tablename__ # type: ignore[attr-defined] - if cls.document_model - else None - ) - cls.fragment_type = ( - cls.fragment_model.__tablename__ # type: ignore[attr-defined] - if cls.fragment_model - else None - ) # Exclude inactive notifications in the registry. It is used to populate the # user's notification preferences screen. diff --git a/funnel/models/notification_types.py b/funnel/models/notification_types.py index 90287f621..a8fc70102 100644 --- a/funnel/models/notification_types.py +++ b/funnel/models/notification_types.py @@ -2,22 +2,21 @@ from __future__ import annotations +from typing import Optional + from baseframe import __ -from ..typing import UuidModelType -from . import Mapped +from .account import Account +from .account_membership import AccountMembership from .comment import Comment, Commentset from .moderation import CommentModeratorReport from .notification import Notification, notification_categories -from .organization_membership import OrganizationMembership -from .profile import Profile from .project import Project -from .project_membership import ProjectCrewMembership +from .project_membership import ProjectMembership from .proposal import Proposal from .rsvp import Rsvp from .session import Session from .update import Update -from .user import Organization, User __all__ = [ 'AccountPasswordNotification', @@ -39,51 +38,45 @@ # --- Protocol and Mixin classes ------------------------------------------------------- -class ProfileSubtype(UuidModelType): - """Model that links to an account (nee profile).""" - - profile: Mapped[Profile] - - -class ProjectSubtype(UuidModelType): - """Model that links to a project.""" - - project: Mapped[Project] - - class DocumentHasProject: """Mixin class for documents linked to a project.""" - document: ProjectSubtype - @property - def preference_context(self) -> Profile: + def preference_context(self) -> Account: """Return document's project's account as preference context.""" - return self.document.project.profile - + return self.document.project.account # type: ignore[attr-defined] -class DocumentHasProfile: - """Mixin class for documents linked to an account (nee profile).""" - document: ProfileSubtype +class DocumentHasAccount: + """Mixin class for documents linked to an account.""" @property - def preference_context(self) -> Profile: + def preference_context(self) -> Account: """Return document's account as preference context.""" - return self.document.profile + return self.document.account # type: ignore[attr-defined] + + +class DocumentIsAccount: + """Mixin class for when the account is the document.""" + + @property + def preference_context(self) -> Account: + """Return document itself as preference context.""" + return self.document # type: ignore[attr-defined] # --- Account notifications ------------------------------------------------------------ -class AccountPasswordNotification(Notification, type='user_password_set'): +class AccountPasswordNotification( + DocumentIsAccount, Notification[Account, None], type='user_password_set' +): """Notification when the user's password changes.""" category = notification_categories.account title = __("When my account password changes") description = __("For your safety, in case this was not authorized") - document_model = User exclude_actor = False roles = ['owner'] for_private_recipient = True @@ -93,7 +86,7 @@ class AccountPasswordNotification(Notification, type='user_password_set'): class RegistrationConfirmationNotification( - DocumentHasProject, Notification, type='rsvp_yes' + DocumentHasProject, Notification[Rsvp, None], type='rsvp_yes' ): """Notification confirming registration to a project.""" @@ -101,7 +94,6 @@ class RegistrationConfirmationNotification( title = __("When I register for a project") description = __("This will prompt a calendar entry in Gmail and other apps") - document_model = Rsvp roles = ['owner'] exclude_actor = False # This is a notification to the actor for_private_recipient = True @@ -109,20 +101,21 @@ class RegistrationConfirmationNotification( class RegistrationCancellationNotification( DocumentHasProject, - Notification, + Notification[Rsvp, None], type='rsvp_no', shadows=RegistrationConfirmationNotification, ): """Notification confirming cancelling registration to a project.""" - document_model = Rsvp roles = ['owner'] exclude_actor = False # This is a notification to the actor for_private_recipient = True allow_web = False -class NewUpdateNotification(DocumentHasProject, Notification, type='update_new'): +class NewUpdateNotification( + DocumentHasProject, Notification[Update, None], type='update_new' +): """Notifications of new updates.""" category = notification_categories.participant @@ -131,13 +124,12 @@ class NewUpdateNotification(DocumentHasProject, Notification, type='update_new') "Typically contains critical information such as video conference links" ) - document_model = Update - roles = ['project_crew', 'project_participant'] + roles = ['project_crew', 'project_participant', 'account_participant'] exclude_actor = False # Send to everyone including the actor class ProposalSubmittedNotification( - DocumentHasProject, Notification, type='proposal_submitted' + DocumentHasProject, Notification[Proposal, None], type='proposal_submitted' ): """Notification to the proposer on a successful proposal submission.""" @@ -145,7 +137,6 @@ class ProposalSubmittedNotification( title = __("When I submit a proposal") description = __("Confirmation for your records") - document_model = Proposal roles = ['creator'] exclude_actor = False # This notification is for the actor @@ -158,7 +149,9 @@ class ProposalSubmittedNotification( class ProjectStartingNotification( - DocumentHasProfile, Notification, type='project_starting' + DocumentHasAccount, + Notification[Project, Optional[Session]], + type='project_starting', ): """Notification of a session about to start.""" @@ -166,8 +159,6 @@ class ProjectStartingNotification( title = __("When a project I’ve registered for is about to start") description = __("You will be notified 5-10 minutes before the starting time") - document_model = Project - fragment_model = Session roles = ['project_crew', 'project_participant'] # This is a notification triggered without an actor @@ -175,27 +166,25 @@ class ProjectStartingNotification( # --- Comment notifications ------------------------------------------------------------ -class NewCommentNotification(Notification, type='comment_new'): +class NewCommentNotification(Notification[Commentset, Comment], type='comment_new'): """Notification of new comment.""" category = notification_categories.participant title = __("When there is a new comment on something I’m involved in") exclude_actor = True - document_model = Commentset - fragment_model = Comment roles = ['replied_to_commenter', 'document_subscriber'] -class CommentReplyNotification(Notification, type='comment_reply'): +class CommentReplyNotification(Notification[Comment, Comment], type='comment_reply'): """Notification of comment replies and mentions.""" category = notification_categories.participant title = __("When someone replies to my comment or mentions me") exclude_actor = True - document_model = Comment # Parent comment (being replied to) - fragment_model = Comment # Child comment (the reply that triggered notification) + # document_model = Parent comment (being replied to) + # fragment_model = Child comment (the reply that triggered notification) roles = ['replied_to_commenter'] @@ -203,7 +192,9 @@ class CommentReplyNotification(Notification, type='comment_reply'): class ProjectCrewMembershipNotification( - DocumentHasProfile, Notification, type='project_crew_membership_granted' + DocumentHasAccount, + Notification[Project, ProjectMembership], + type='project_crew_membership_granted', ): """Notification of being granted crew membership (including role changes).""" @@ -211,42 +202,36 @@ class ProjectCrewMembershipNotification( title = __("When a project crew member is added or removed") description = __("Crew members have access to the project’s settings and data") - document_model = Project - fragment_model = ProjectCrewMembership - roles = ['subject', 'project_crew'] + roles = ['member', 'project_crew'] exclude_actor = True # Alerts other users of actor's actions; too noisy for actor class ProjectCrewMembershipRevokedNotification( - DocumentHasProfile, - Notification, + DocumentHasAccount, + Notification[Project, ProjectMembership], type='project_crew_membership_revoked', shadows=ProjectCrewMembershipNotification, ): """Notification of being removed from crew membership (including role changes).""" - document_model = Project - fragment_model = ProjectCrewMembership - roles = ['subject', 'project_crew'] + roles = ['member', 'project_crew'] exclude_actor = True # Alerts other users of actor's actions; too noisy for actor class ProposalReceivedNotification( - DocumentHasProfile, Notification, type='proposal_received' + DocumentHasAccount, Notification[Project, Proposal], type='proposal_received' ): """Notification to editors of new proposals.""" category = notification_categories.project_crew title = __("When my project receives a new proposal") - document_model = Project - fragment_model = Proposal roles = ['project_editor'] exclude_actor = True # Don't notify editor of proposal they submitted class RegistrationReceivedNotification( - DocumentHasProfile, Notification, type='rsvp_received' + DocumentHasAccount, Notification[Project, Rsvp], type='rsvp_received' ): """Notification to promoters of new registrations.""" @@ -255,8 +240,6 @@ class RegistrationReceivedNotification( category = notification_categories.project_crew title = __("When someone registers for my project") - document_model = Project - fragment_model = Rsvp roles = ['project_promoter'] exclude_actor = True @@ -265,7 +248,9 @@ class RegistrationReceivedNotification( class OrganizationAdminMembershipNotification( - DocumentHasProfile, Notification, type='organization_membership_granted' + DocumentHasAccount, + Notification[Account, AccountMembership], + type='organization_membership_granted', ): """Notification of being granted admin membership (including role changes).""" @@ -273,35 +258,31 @@ class OrganizationAdminMembershipNotification( title = __("When account admins change") description = __("Account admins control all projects under the account") - document_model = Organization - fragment_model = OrganizationMembership - roles = ['subject', 'profile_admin'] + roles = ['member', 'account_admin'] exclude_actor = True # Alerts other users of actor's actions; too noisy for actor class OrganizationAdminMembershipRevokedNotification( - DocumentHasProfile, - Notification, + DocumentHasAccount, + Notification[Account, AccountMembership], type='organization_membership_revoked', shadows=OrganizationAdminMembershipNotification, ): """Notification of being granted admin membership (including role changes).""" - document_model = Organization - fragment_model = OrganizationMembership - roles = ['subject', 'profile_admin'] + roles = ['member', 'account_admin'] exclude_actor = True # Alerts other users of actor's actions; too noisy for actor # --- Site administrator notifications ------------------------------------------------- -class CommentReportReceivedNotification(Notification, type='comment_report_received'): +class CommentReportReceivedNotification( + Notification[Comment, CommentModeratorReport], type='comment_report_received' +): """Notification for comment moderators when a comment is reported as spam.""" category = notification_categories.site_admin title = __("When a comment is reported as spam") - document_model = Comment - fragment_model = CommentModeratorReport roles = ['comment_moderator'] diff --git a/funnel/models/organization_membership.py b/funnel/models/organization_membership.py deleted file mode 100644 index b169d5eda..000000000 --- a/funnel/models/organization_membership.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Membership model for admins of an organization.""" - -from __future__ import annotations - -from typing import Set -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - -from werkzeug.utils import cached_property - -from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles - -from . import Mapped, db, sa -from .helpers import reopen -from .membership_mixin import ImmutableUserMembershipMixin -from .user import Organization, User - -__all__ = ['OrganizationMembership'] - - -class OrganizationMembership( - ImmutableUserMembershipMixin, - db.Model, # type: ignore[name-defined] -): - """ - A user can be an administrator of an organization and optionally an owner. - - Owners can manage other administrators. 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__ = 'organization_membership' - __allow_unmapped__ = True - - # 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', - 'user', - 'is_owner', - 'organization', - 'granted_by', - 'revoked_by', - 'granted_at', - 'revoked_at', - 'is_self_granted', - 'is_self_revoked', - } - }, - 'profile_admin': { - 'read': { - 'record_type', - 'record_type_label', - 'granted_at', - 'granted_by', - 'revoked_at', - 'revoked_by', - 'user', - 'is_active', - 'is_invite', - 'is_self_granted', - 'is_self_revoked', - } - }, - } - __datasets__ = { - 'primary': { - 'urls', - 'uuid_b58', - 'offered_roles', - 'is_owner', - 'user', - 'organization', - }, - 'without_parent': {'urls', 'uuid_b58', 'offered_roles', 'is_owner', 'user'}, - 'related': {'urls', 'uuid_b58', 'offered_roles', 'is_owner'}, - } - - #: Organization that this membership is being granted on - organization_id = sa.Column( - sa.Integer, - sa.ForeignKey('organization.id', ondelete='CASCADE'), - nullable=False, - ) - organization = with_roles( - sa.orm.relationship( - Organization, - backref=sa.orm.backref( - 'memberships', lazy='dynamic', cascade='all', passive_deletes=True - ), - ), - grants_via={None: {'admin': 'profile_admin', 'owner': 'profile_owner'}}, - ) - parent_id: Mapped[int] = sa.orm.synonym('organization_id') - parent_id_column = 'organization_id' - parent: Mapped[Organization] = sa.orm.synonym('organization') - - # Organization roles: - is_owner = immutable(sa.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 Organization and User -# Organization.active_memberships is a future possibility. For now just admin and owner -@reopen(Organization) -class __Organization: - active_admin_memberships = with_roles( - sa.orm.relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.organization_id) - == Organization.id, - OrganizationMembership.is_active, # type: ignore[arg-type] - ), - order_by=OrganizationMembership.granted_at.asc(), - viewonly=True, - ), - grants_via={'user': {'admin', 'owner'}}, - ) - - active_owner_memberships = sa.orm.relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.organization_id) == Organization.id, - OrganizationMembership.is_active, # type: ignore[arg-type] - OrganizationMembership.is_owner.is_(True), - ), - viewonly=True, - ) - - active_invitations = sa.orm.relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.organization_id) == Organization.id, - OrganizationMembership.is_invite, # type: ignore[arg-type] - OrganizationMembership.revoked_at.is_(None), - ), - viewonly=True, - ) - - owner_users = with_roles( - DynamicAssociationProxy('active_owner_memberships', 'user'), read={'all'} - ) - admin_users = with_roles( - DynamicAssociationProxy('active_admin_memberships', 'user'), read={'all'} - ) - - -# User.active_organization_memberships is a future possibility. -# For now just admin and owner -@reopen(User) -class __User: - # pylint: disable=invalid-unary-operand-type - organization_admin_memberships = sa.orm.relationship( - OrganizationMembership, - lazy='dynamic', - foreign_keys=[OrganizationMembership.user_id], # type: ignore[has-type] - viewonly=True, - ) - - noninvite_organization_admin_memberships = sa.orm.relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.user_id) # type: ignore[has-type] - == User.id, - ~OrganizationMembership.is_invite, # type: ignore[operator] - ), - viewonly=True, - ) - - active_organization_admin_memberships = sa.orm.relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.user_id) # type: ignore[has-type] - == User.id, - OrganizationMembership.is_active, # type: ignore[arg-type] - ), - viewonly=True, - ) - - active_organization_owner_memberships = sa.orm.relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.user_id) # type: ignore[has-type] - == User.id, - OrganizationMembership.is_active, # type: ignore[arg-type] - OrganizationMembership.is_owner.is_(True), - ), - viewonly=True, - ) - - active_organization_invitations = sa.orm.relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.user_id) # type: ignore[has-type] - == User.id, - OrganizationMembership.is_invite, # type: ignore[arg-type] - OrganizationMembership.revoked_at.is_(None), - ), - viewonly=True, - ) - - organizations_as_owner = DynamicAssociationProxy( - 'active_organization_owner_memberships', 'organization' - ) - - organizations_as_admin = DynamicAssociationProxy( - 'active_organization_admin_memberships', 'organization' - ) - - -User.__active_membership_attrs__.add('active_organization_admin_memberships') -User.__noninvite_membership_attrs__.add('noninvite_organization_admin_memberships') diff --git a/funnel/models/phone_number.py b/funnel/models/phone_number.py index 788d15bb2..f896267d0 100644 --- a/funnel/models/phone_number.py +++ b/funnel/models/phone_number.py @@ -2,20 +2,17 @@ from __future__ import annotations -from typing import Any, Optional, Set, Union, overload import hashlib +from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload +import base58 +import phonenumbers 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 typing_extensions import Literal -import base58 -import phonenumbers - from baseframe import _ from coaster.sqlalchemy import immutable, with_roles from coaster.utils import require_one_of @@ -24,10 +21,12 @@ from . import ( BaseMixin, Mapped, + Model, db, declarative_mixin, declared_attr, hybrid_property, + relationship, sa, ) @@ -98,50 +97,50 @@ class PhoneNumberInUseError(PhoneNumberError): @overload -def parse_phone_number(candidate: str) -> Optional[str]: +def parse_phone_number(candidate: str) -> str | None: ... @overload -def parse_phone_number(candidate: str, sms: Literal[False]) -> Optional[str]: +def parse_phone_number(candidate: str, sms: Literal[False]) -> str | None: ... @overload def parse_phone_number( candidate: str, sms: Literal[False], parsed: Literal[True] -) -> Optional[phonenumbers.PhoneNumber]: +) -> phonenumbers.PhoneNumber | None: ... @overload def parse_phone_number( - candidate: str, sms: Union[bool, Literal[True]] -) -> Optional[Union[str, Literal[False]]]: + candidate: str, sms: bool | Literal[True] +) -> str | Literal[False] | None: ... @overload def parse_phone_number( candidate: str, - sms: Union[bool, Literal[True]], + sms: bool | Literal[True], parsed: Literal[True], -) -> Optional[Union[phonenumbers.PhoneNumber, Literal[False]]]: +) -> phonenumbers.PhoneNumber | Literal[False] | None: ... @overload def parse_phone_number( candidate: str, - sms: Union[bool, Literal[True]], - parsed: Union[bool, Literal[False]], -) -> Optional[Union[phonenumbers.PhoneNumber, Literal[False]]]: + sms: bool | Literal[True], + parsed: bool | Literal[False], +) -> phonenumbers.PhoneNumber | Literal[False] | None: ... def parse_phone_number( candidate: str, sms: bool = False, parsed: bool = False -) -> Optional[Union[str, phonenumbers.PhoneNumber, Literal[False]]]: +) -> str | phonenumbers.PhoneNumber | Literal[False] | None: """ Attempt to parse and validate a phone number and return in E164 format. @@ -151,6 +150,8 @@ def parse_phone_number( :param sms: Validate that the number is from a range that supports SMS delivery, returning `False` if it isn't + :param parsed: Return :class:`phonenumbers.PhoneNumber` object instead of a string + :returns: E164-formatted phone number if found and valid, `None` if not found, or `False` if the number is valid but does not support SMS delivery """ @@ -159,19 +160,18 @@ def parse_phone_number( # candidate that is likely to be a valid number. This behaviour differentiates it # from similar code in :func:`~funnel.models.utils.getuser`, where the loop exits # with the _last_ valid candidate (as it's coupled with a - # :class:`~funnel.models.user.UserPhone` lookup) + # :class:`~funnel.models.account.AccountPhone` lookup) sms_invalid = False try: for region in PHONE_LOOKUP_REGIONS: parsed_number = phonenumbers.parse(candidate, region) if phonenumbers.is_valid_number(parsed_number): - if sms: - if phonenumbers.number_type(parsed_number) not in ( - phonenumbers.PhoneNumberType.MOBILE, - phonenumbers.PhoneNumberType.FIXED_LINE_OR_MOBILE, - ): - sms_invalid = True - continue # Not valid for SMS, continue searching regions + if sms and phonenumbers.number_type(parsed_number) not in ( + phonenumbers.PhoneNumberType.MOBILE, + phonenumbers.PhoneNumberType.FIXED_LINE_OR_MOBILE, + ): + sms_invalid = True + continue # Not valid for SMS, continue searching regions if parsed: return parsed_number return phonenumbers.format_number( @@ -186,7 +186,7 @@ def parse_phone_number( return None -def validate_phone_number(candidate: Union[str, phonenumbers.PhoneNumber]) -> str: +def validate_phone_number(candidate: str | phonenumbers.PhoneNumber) -> str: """ Validate an international phone number and return in E164 format. @@ -206,7 +206,7 @@ def validate_phone_number(candidate: Union[str, phonenumbers.PhoneNumber]) -> st raise PhoneNumberInvalidError(f"Not a valid phone number: {candidate}") -def canonical_phone_number(candidate: Union[str, phonenumbers.PhoneNumber]) -> str: +def canonical_phone_number(candidate: str | phonenumbers.PhoneNumber) -> str: """Normalize an international phone number by rendering in E164 format.""" if not isinstance(candidate, phonenumbers.PhoneNumber): try: @@ -217,7 +217,7 @@ def canonical_phone_number(candidate: Union[str, phonenumbers.PhoneNumber]) -> s def phone_blake2b160_hash( - phone: Union[str, phonenumbers.PhoneNumber], + phone: str | phonenumbers.PhoneNumber, *, _pre_validated_formatted: bool = False, ) -> bytes: @@ -232,12 +232,12 @@ def phone_blake2b160_hash( # --- Models --------------------------------------------------------------------------- -class PhoneNumber(BaseMixin, db.Model): # type: ignore[name-defined] +class PhoneNumber(BaseMixin, Model): """ Represents a phone number as a standalone entity, with associated metadata. Prior to this model, phone numbers were stored in the - :class:`~funnel.models.user.UserPhone` and + :class:`~funnel.models.account.AccountPhone` and :class:`~funnel.models.notification.SmsMessage models, with no ability to store preferences against a number, such as enforcing a block list or scraping against mobile number revocation lists. @@ -250,24 +250,23 @@ class PhoneNumber(BaseMixin, db.Model): # type: ignore[name-defined] """ __tablename__ = 'phone_number' - __allow_unmapped__ = True #: Backrefs to this model from other models, populated by :class:`PhoneNumberMixin` #: Contains the name of the relationship in the :class:`PhoneNumber` model - __backrefs__: Set[str] = set() + __backrefs__: ClassVar[set[str]] = set() #: These backrefs claim exclusive use of the phone number for their linked owner. #: See :class:`PhoneNumberMixin` for implementation detail - __exclusive_backrefs__: Set[str] = set() + __exclusive_backrefs__: ClassVar[set[str]] = set() #: The phone number, centrepiece of this model. Stored normalized in E164 format. #: Validated by the :func:`_validate_phone` event handler - number = sa.Column(sa.Unicode, nullable=True, unique=True) + number = sa.orm.mapped_column(sa.Unicode, nullable=True, unique=True) #: BLAKE2b 160-bit hash of :attr:`phone`. Kept permanently even if phone 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( 'LENGTH(blake2b160) = 20', @@ -283,41 +282,45 @@ class PhoneNumber(BaseMixin, db.Model): # type: ignore[name-defined] # device, we record distinct timestamps for last sent, delivery and failure. #: Cached state for whether this phone number is known to have SMS support - has_sms = sa.Column(sa.Boolean, nullable=True) + has_sms = sa.orm.mapped_column(sa.Boolean, nullable=True) #: Timestamp at which this number was determined to be valid/invalid for SMS - has_sms_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + has_sms_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) #: Cached state for whether this phone number is known to be on WhatsApp or not - has_wa = sa.Column(sa.Boolean, nullable=True) + has_wa = sa.orm.mapped_column(sa.Boolean, nullable=True) #: Timestamp at which this number was tested for availability on WhatsApp - has_wa_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + has_wa_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) #: Timestamp of last SMS sent - msg_sms_sent_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_sms_sent_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) #: Timestamp of last SMS delivered - msg_sms_delivered_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_sms_delivered_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Timestamp of last SMS delivery failure - msg_sms_failed_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_sms_failed_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) #: Timestamp of last WA message sent - msg_wa_sent_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_wa_sent_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) #: Timestamp of last WA message delivered - msg_wa_delivered_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_wa_delivered_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Timestamp of last WA message delivery failure - msg_wa_failed_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_wa_failed_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) #: Timestamp of last known recipient activity resulting from sent messages - active_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + active_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) #: Is this phone number blocked from being used? :attr:`phone` should be null if so. - blocked_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + blocked_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) __table_args__ = ( # If `blocked_at` is not None, `number` and `has_*` must be None sa.CheckConstraint( - sa.or_( # type: ignore[arg-type] + sa.or_( blocked_at.is_(None), # or... sa.and_( - blocked_at.isnot(None), + blocked_at.is_not(None), number.is_(None), has_sms.is_(None), has_sms_at.is_(None), @@ -330,20 +333,21 @@ class PhoneNumber(BaseMixin, db.Model): # type: ignore[name-defined] sa.CheckConstraint( sa.or_( sa.and_(has_sms.is_(None), has_sms_at.is_(None)), - sa.and_(has_sms.isnot(None), has_sms_at.isnot(None)), + sa.and_(has_sms.is_not(None), has_sms_at.is_not(None)), ), 'phone_number_has_sms_check', ), sa.CheckConstraint( sa.or_( sa.and_(has_wa.is_(None), has_wa_at.is_(None)), - sa.and_(has_wa.isnot(None), has_wa_at.isnot(None)), + sa.and_(has_wa.is_not(None), has_wa_at.is_not(None)), ), 'phone_number_has_wa_check', ), ) def __init__(self, phone: str, *, _pre_validated_formatted: bool = False) -> None: + super().__init__() if not isinstance(phone, str): raise ValueError("A string phone number is required") if not _pre_validated_formatted: @@ -374,10 +378,11 @@ def is_blocked(self) -> bool: with db.session.no_autoflush: return self.blocked_at is not None - @is_blocked.expression - def is_blocked(cls): # noqa: N805 # pylint: disable=no-self-argument + @is_blocked.inplace.expression + @classmethod + def _is_blocked_expression(cls) -> sa.ColumnElement[bool]: """Expression form of is_blocked check.""" - return cls.blocked_at.isnot(None) + return cls.blocked_at.is_not(None) @with_roles(read={'all'}) @cached_property @@ -389,19 +394,16 @@ def phone_hash(self) -> str: transport_hash = phone_hash @with_roles(call={'all'}) - def md5(self) -> Optional[str]: + def md5(self) -> str | None: """MD5 hash of :attr:`phone`, for legacy use only.""" - # TODO: After upgrading to Python 3.9, use usedforsecurity=False return ( - hashlib.md5( # nosec # skipcq: PTC-W1003 - self.number.encode('utf-8') - ).hexdigest() + hashlib.md5(self.number.encode('utf-8'), usedforsecurity=False).hexdigest() if self.number else None ) @cached_property - def parsed(self) -> Optional[phonenumbers.PhoneNumber]: + def parsed(self) -> phonenumbers.PhoneNumber | None: """Return parsed phone number using libphonenumbers.""" if self.number: return phonenumbers.parse(self.number) @@ -427,8 +429,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 PhoneNumber is available for the given owner.""" + def is_available_for(self, owner: Account | None) -> bool: + """Return True if this PhoneNumber 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.__phone_for__) @@ -494,18 +496,18 @@ def mark_unblocked(self, phone: str) -> None: @overload @classmethod def get_filter( - cls, *, phone: Union[str, phonenumbers.PhoneNumber] - ) -> Optional[ColumnElement]: + cls, *, phone: str | phonenumbers.PhoneNumber + ) -> ColumnElement[bool]: ... @overload @classmethod - def get_filter(cls, *, blake2b160: bytes) -> ColumnElement: + def get_filter(cls, *, blake2b160: bytes) -> ColumnElement[bool]: ... @overload @classmethod - def get_filter(cls, *, phone_hash: str) -> ColumnElement: + def get_filter(cls, *, phone_hash: str) -> ColumnElement[bool]: ... @overload @@ -513,20 +515,20 @@ def get_filter(cls, *, phone_hash: str) -> ColumnElement: def get_filter( cls, *, - phone: Optional[Union[str, phonenumbers.PhoneNumber]], - blake2b160: Optional[bytes], - phone_hash: Optional[str], - ) -> Optional[ColumnElement]: + phone: str | phonenumbers.PhoneNumber | None, + blake2b160: bytes | None, + phone_hash: str | None, + ) -> ColumnElement[bool]: ... @classmethod def get_filter( cls, *, - phone: Optional[Union[str, phonenumbers.PhoneNumber]] = None, - blake2b160: Optional[bytes] = None, - phone_hash: Optional[str] = None, - ) -> Optional[ColumnElement]: + phone: str | phonenumbers.PhoneNumber | None = None, + blake2b160: bytes | None = None, + phone_hash: str | None = None, + ) -> ColumnElement[bool]: """ Get an filter condition for retriving a :class:`PhoneNumber`. @@ -546,10 +548,10 @@ def get_filter( @classmethod def get( cls, - phone: Union[str, phonenumbers.PhoneNumber], + phone: str | phonenumbers.PhoneNumber, *, - is_blocked: Optional[bool] = None, - ) -> Optional[PhoneNumber]: + is_blocked: bool | None = None, + ) -> PhoneNumber | None: ... @overload @@ -558,8 +560,8 @@ def get( cls, *, blake2b160: bytes, - is_blocked: Optional[bool] = None, - ) -> Optional[PhoneNumber]: + is_blocked: bool | None = None, + ) -> PhoneNumber | None: ... @overload @@ -568,19 +570,19 @@ def get( cls, *, phone_hash: str, - is_blocked: Optional[bool] = None, - ) -> Optional[PhoneNumber]: + is_blocked: bool | None = None, + ) -> PhoneNumber | None: ... @classmethod def get( cls, - phone: Optional[Union[str, phonenumbers.PhoneNumber]] = None, + phone: str | phonenumbers.PhoneNumber | None = None, *, - blake2b160: Optional[bytes] = None, - phone_hash: Optional[str] = None, - is_blocked: Optional[bool] = None, - ) -> Optional[PhoneNumber]: + blake2b160: bytes | None = None, + phone_hash: str | None = None, + is_blocked: bool | None = None, + ) -> PhoneNumber | None: """ Get an :class:`PhoneNumber` instance by normalized phone number or its hash. @@ -596,13 +598,13 @@ def get( return None # phone number was not valid if is_blocked is not None: if is_blocked: - query = query.filter(cls.blocked_at.isnot(None)) + query = query.filter(cls.blocked_at.is_not(None)) else: query = query.filter(cls.blocked_at.is_(None)) return query.one_or_none() @classmethod - def add(cls, phone: Union[str, phonenumbers.PhoneNumber]) -> PhoneNumber: + def add(cls, phone: str | phonenumbers.PhoneNumber) -> PhoneNumber: """ Create a new :class:`PhoneNumber` after normalization and validation. @@ -628,7 +630,9 @@ def add(cls, phone: Union[str, phonenumbers.PhoneNumber]) -> PhoneNumber: @classmethod def add_for( - cls, owner: Optional[object], phone: Union[str, phonenumbers.PhoneNumber] + cls, + owner: Account | None, + phone: str | phonenumbers.PhoneNumber, ) -> PhoneNumber: """ Create a new :class:`PhoneNumber` after validation. @@ -655,19 +659,20 @@ def add_for( @classmethod def validate_for( cls, - owner: Optional[object], - phone: Union[str, phonenumbers.PhoneNumber], + owner: Account | None, + phone: str | phonenumbers.PhoneNumber, new: bool = False, - ) -> Union[bool, Literal['invalid', 'not_new', 'blocked']]: + ) -> Literal['taken', 'invalid', 'not_new', 'blocked'] | None: """ - Validate whether the phone number is available to the given owner. + Validate whether the phone number is available to the proposed owner. - Returns False if the number 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. 'not_new': Phone number is already attached to owner (if `new` is True) - 2. 'invalid': Invalid syntax and therefore unusable - 3. 'blocked': Phone number has been blocked from use + 1. 'taken': Phone number has another owner + 2. 'not_new': Phone number is already attached to owner (if `new` is True) + 3. 'invalid': Invalid syntax and therefore unusable + 4. 'blocked': Phone number has been blocked from use :param owner: Proposed owner of this phone number (may be None) :param phone: Phone number to validate @@ -679,20 +684,33 @@ def validate_for( return 'invalid' existing = cls.get(phone) if existing is None: - return True + return None # There's an existing? Is it blocked? if existing.is_blocked: return 'blocked' # Is the existing phone mumber available for this owner? if not existing.is_available_for(owner): # Not available, so return False - return False + return 'taken' # Caller is asking to confirm this is not already belonging to this owner if new and existing.is_exclusive(): # It's in an exclusive relationship, and we're already determined it's # available to this owner, so it must be exclusive to them return 'not_new' - return True + return None + + @classmethod + def get_numbers(cls, prefix: str, remove: bool = True) -> set[str]: + """Get all numbers with the given prefix as a Python set.""" + query = ( + cls.query.filter(cls.number.startswith(prefix)) + .options(sa.orm.load_only(cls.number)) + .yield_per(1000) + ) + if remove: + skip = len(prefix) + return {r.number[skip:] for r in query} + return {r.number for r in query} @declarative_mixin @@ -709,20 +727,20 @@ class PhoneNumberMixin: __tablename__: str #: This class has an optional dependency on PhoneNumber - __phone_optional__: bool = True + __phone_optional__: ClassVar[bool] = True #: This class has a unique constraint on the fkey to PhoneNumber - __phone_unique__: bool = False + __phone_unique__: ClassVar[bool] = False #: A relationship from this model is for the (single) owner at this attr - __phone_for__: Optional[str] = None + __phone_for__: ClassVar[str | None] = None #: If `__phone_for__` is specified and this flag is True, the phone number is #: considered exclusive to this owner and may not be used by any other owner - __phone_is_exclusive__: bool = False + __phone_is_exclusive__: ClassVar[bool] = False @declared_attr @classmethod def phone_number_id(cls) -> Mapped[int]: """Foreign key to phone_number table.""" - return sa.Column( + return sa.orm.mapped_column( sa.Integer, sa.ForeignKey('phone_number.id', ondelete='SET NULL'), nullable=cls.__phone_optional__, @@ -731,18 +749,17 @@ def phone_number_id(cls) -> Mapped[int]: ) @declared_attr - def phone_number( # pylint: disable=no-self-argument - cls, - ) -> Mapped[PhoneNumber]: + @classmethod + def phone_number(cls) -> Mapped[PhoneNumber]: """Instance of :class:`PhoneNumber` as a relationship.""" backref_name = 'used_in_' + cls.__tablename__ PhoneNumber.__backrefs__.add(backref_name) if cls.__phone_for__ and cls.__phone_is_exclusive__: PhoneNumber.__exclusive_backrefs__.add(backref_name) - return sa.orm.relationship(PhoneNumber, backref=backref_name) + return relationship(PhoneNumber, backref=backref_name) @property - def phone(self) -> Optional[str]: + def phone(self) -> str | None: """ Shorthand for ``self.phone_number.number``. @@ -759,7 +776,7 @@ def phone(self) -> Optional[str]: return None @phone.setter - def phone(self, value: Optional[str]) -> None: + def phone(self, value: str | None) -> None: if self.__phone_for__: if value is not None: self.phone_number = PhoneNumber.add_for( @@ -784,7 +801,7 @@ def phone_number_reference_is_active(self) -> bool: return True @property - def transport_hash(self) -> Optional[str]: + def transport_hash(self) -> str | None: """Phone hash using the compatibility name for notifications framework.""" return ( self.phone_number.phone_hash @@ -804,7 +821,9 @@ def _clear_cached_properties(target: PhoneNumber) -> None: @event.listens_for(PhoneNumber.number, 'set', retval=True) -def _validate_number(target: PhoneNumber, value: Any, old_value: Any, initiator) -> Any: +def _validate_number( + target: PhoneNumber, value: Any, old_value: Any, _initiator: Any +) -> Any: # First: check if value is acceptable and phone attribute can be set if not value and value is not None: # Only `None` is an acceptable falsy value @@ -840,16 +859,20 @@ def _validate_number(target: PhoneNumber, value: Any, old_value: Any, initiator) raise ValueError(f"Invalid value for phone number: {value}") -def _send_refcount_event_remove(target, value, initiator): +def _send_refcount_event_remove( + target: PhoneNumber, _value: Any, _initiator: Any +) -> None: phonenumber_refcount_dropping.send(target) -def _send_refcount_event_before_delete(mapper_, connection, target): +def _send_refcount_event_before_delete( + _mapper: Any, _connection: Any, target: PhoneNumberMixin +) -> None: if target.phone_number: phonenumber_refcount_dropping.send(target.phone_number) -@event.listens_for(mapper, 'after_configured') +@event.listens_for(Mapper, 'after_configured') def _setup_refcount_events() -> None: for backref_name in PhoneNumber.__backrefs__: attr = getattr(PhoneNumber, backref_name) @@ -857,17 +880,25 @@ def _setup_refcount_events() -> None: def _phone_number_mixin_set_validator( - target, value: Optional[PhoneNumber], old_value, initiator + target: PhoneNumberMixin, + value: PhoneNumber | None, + old_value: PhoneNumber | None, + _initiator: Any, ) -> None: - if value != old_value and target.__phone_for__: - if value is not None: - if value.is_blocked: - raise PhoneNumberBlockedError("This phone number has been blocked") - if not value.is_available_for(getattr(target, target.__phone_for__)): - raise PhoneNumberInUseError("This phone number it not available") + if value is not None and value != old_value and target.__phone_for__: + if value.is_blocked: + raise PhoneNumberBlockedError("This phone number has been blocked") + if not value.is_available_for(getattr(target, target.__phone_for__)): + raise PhoneNumberInUseError("This phone number it not available") @event.listens_for(PhoneNumberMixin, 'mapper_configured', propagate=True) -def _phone_number_mixin_configure_events(mapper_, cls: PhoneNumberMixin): +def _phone_number_mixin_configure_events( + _mapper: Any, cls: type[PhoneNumberMixin] +) -> None: event.listen(cls.phone_number, 'set', _phone_number_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/profile.py b/funnel/models/profile.py deleted file mode 100644 index 7cd9600a9..000000000 --- a/funnel/models/profile.py +++ /dev/null @@ -1,523 +0,0 @@ -"""Account (nee Profile) model, linked to a User or Organization model.""" - -from __future__ import annotations - -from typing import Iterable, List, Optional, Union -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - -from sqlalchemy.sql import expression - -from furl import furl - -from baseframe import __ -from coaster.sqlalchemy import LazyRoleSet, Query, StateManager, immutable, with_roles -from coaster.utils import LabeledEnum - -from ..typing import OptionalMigratedTables -from . import ( - BaseMixin, - Mapped, - MarkdownCompositeDocument, - TSVectorType, - UrlType, - UuidMixin, - db, - hybrid_property, - sa, -) -from .helpers import ( - RESERVED_NAMES, - ImgeeFurl, - ImgeeType, - add_search_trigger, - quote_autocomplete_tsquery, - valid_username, - visual_field_delimiter, -) -from .user import EnumerateMembershipsMixin, Organization, User -from .utils import do_migrate_instances - -__all__ = ['Profile'] - - -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} - - -# This model does not use BaseNameMixin because it has no title column. The title comes -# from the linked User or Organization -class Profile( - EnumerateMembershipsMixin, - UuidMixin, - BaseMixin, - db.Model, # type: ignore[name-defined] -): - """ - Consolidated account for :class:`User` and :class:`Organization` models. - - Accounts (nee Profiles) hold the account name in a shared namespace between these - models (aka "username"), and also host projects and other future document types. - """ - - __tablename__ = 'profile' - __allow_unmapped__ = True - __uuid_primary_key__ = False - # length limit 63 to fit DNS label limit - __name_length__ = 63 - reserved_names = RESERVED_NAMES - - #: The "username" assigned to a user or organization. - #: Length limit 63 to fit DNS label limit - name = sa.Column( - sa.Unicode(__name_length__), - sa.CheckConstraint("name <> ''"), - nullable=False, - unique=True, - ) - # Only one of the following three may be set: - #: User that owns this name (limit one per user) - user_id = sa.Column( - sa.Integer, - sa.ForeignKey('user.id', ondelete='SET NULL'), - unique=True, - nullable=True, - ) - - # No `cascade='delete-orphan'` in User and Organization backrefs as accounts cannot - # be trivially deleted - - user: Mapped[Optional[User]] = with_roles( - sa.orm.relationship( - 'User', - backref=sa.orm.backref('profile', uselist=False, cascade='all'), - ), - grants={'owner'}, - ) - #: Organization that owns this name (limit one per organization) - organization_id = sa.Column( - sa.Integer, - sa.ForeignKey('organization.id', ondelete='SET NULL'), - unique=True, - nullable=True, - ) - organization: Mapped[Optional[Organization]] = sa.orm.relationship( - 'Organization', - backref=sa.orm.backref('profile', uselist=False, cascade='all'), - ) - #: Reserved account (not assigned to any party) - reserved = sa.Column(sa.Boolean, nullable=False, default=False, index=True) - - _state = sa.Column( - 'state', - sa.Integer, - StateManager.check_constraint('state', PROFILE_STATE), - nullable=False, - default=PROFILE_STATE.AUTO, - ) - state = StateManager( - '_state', PROFILE_STATE, doc="Current state of the account page" - ) - - tagline = sa.Column(sa.Unicode, nullable=True) - description = MarkdownCompositeDocument.create( - 'description', default='', nullable=False - ) - website: Mapped[Optional[furl]] = sa.Column(UrlType, nullable=True) - logo_url: Mapped[Optional[ImgeeFurl]] = sa.Column(ImgeeType, nullable=True) - banner_image_url: Mapped[Optional[ImgeeFurl]] = sa.Column(ImgeeType, 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 = with_roles( - immutable(sa.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 = with_roles( - immutable(sa.Column(sa.Boolean, default=False, nullable=False, index=True)), - read={'all'}, - ) - - #: Revision number maintained by SQLAlchemy, starting at 1 - revisionid = with_roles(sa.Column(sa.Integer, nullable=False), read={'all'}) - - search_vector: Mapped[TSVectorType] = sa.orm.deferred( - sa.Column( - TSVectorType( - 'name', - 'description_text', - weights={'name': 'A', 'description_text': 'B'}, - regconfig='english', - hltext=lambda: sa.func.concat_ws( - visual_field_delimiter, Profile.name, Profile.description_html - ), - ), - nullable=False, - ) - ) - - is_active = with_roles( - sa.orm.column_property( - sa.case( - ( - user_id.isnot(None), # ← when, ↙ then - sa.select(User.state.ACTIVE) # type: ignore[has-type] - .where(User.id == user_id) - .correlate_except(User) # type: ignore[arg-type] - .scalar_subquery(), - ), - ( - organization_id.isnot(None), # ← when, ↙ then - sa.select(Organization.state.ACTIVE) # type: ignore[has-type] - .where(Organization.id == organization_id) - .correlate_except(Organization) # type: ignore[arg-type] - .scalar_subquery(), - ), - else_=expression.false(), - ) - ), - read={'all'}, - datasets={'primary', 'related'}, - ) - - __table_args__ = ( - sa.CheckConstraint( - sa.case((user_id.isnot(None), 1), else_=0) - + sa.case((organization_id.isnot(None), 1), else_=0) - + sa.case((reserved.is_(True), 1), else_=0) - == 1, - name='profile_owner_check', - ), - sa.Index( - 'ix_profile_name_lower', - sa.func.lower(name).label('name_lower'), - unique=True, - postgresql_ops={'name_lower': 'varchar_pattern_ops'}, - ), - sa.Index('ix_profile_search_vector', 'search_vector', postgresql_using='gin'), - ) - - __mapper_args__ = {'version_id_col': revisionid} - - __roles__ = { - 'all': { - 'read': { - 'urls', - 'uuid_b58', - 'name', - 'title', - 'description', - 'website', - 'logo_url', - 'user', - 'organization', - 'banner_image_url', - 'is_organization_profile', - 'is_user_profile', - 'owner', - }, - 'call': {'url_for', 'features', 'forms', 'state', 'views'}, - } - } - - __datasets__ = { - 'primary': { - 'urls', - 'uuid_b58', - 'name', - 'title', - 'description', - 'logo_url', - 'website', - 'user', - 'organization', - 'owner', - 'is_verified', - }, - 'related': { - 'urls', - 'uuid_b58', - 'name', - 'title', - 'description', - 'logo_url', - 'is_verified', - }, - } - - state.add_conditional_state( - 'ACTIVE_AND_PUBLIC', state.PUBLIC, lambda profile: profile.is_active - ) - - def __repr__(self) -> str: - """Represent :class:`Profile` as a string.""" - return f'' - - @property - def owner(self) -> Union[User, Organization]: - """Return the user or organization that owns this account.""" - return self.user or self.organization - - @owner.setter - def owner(self, value: Union[User, Organization]) -> None: - if isinstance(value, User): - self.user = value - self.organization = None - elif isinstance(value, Organization): - self.user = None - self.organization = value - else: - raise ValueError(value) - self.reserved = False - - @hybrid_property - def is_user_profile(self) -> bool: - """Test if this is a user account.""" - return self.user_id is not None - - @is_user_profile.expression - def is_user_profile(cls): # noqa: N805 # pylint: disable=no-self-argument - """Test if this is a user account in a SQL expression.""" - return cls.user_id.isnot(None) - - @hybrid_property - def is_organization_profile(self) -> bool: - """Test if this is an organization account.""" - return self.organization_id is not None - - @is_organization_profile.expression - def is_organization_profile(cls): # noqa: N805 # pylint: disable=no-self-argument - """Test if this is an organization account in a SQL expression.""" - return cls.organization_id.isnot(None) - - @property - def is_public(self) -> bool: - """Test if this account is public.""" - return bool(self.state.PUBLIC) - - with_roles(is_public, read={'all'}) - - @hybrid_property - def title(self) -> str: - if self.user: - return self.user.fullname - if self.organization: - return self.organization.title - return '' - - @title.setter - def title(self, value: str) -> None: - if self.user: - self.user.fullname = value - elif self.organization: - self.organization.title = value - else: - raise ValueError("Reserved accounts do not have titles") - - @title.expression - def title(cls): # noqa: N805 # pylint: disable=no-self-argument - return sa.case( - ( - # if... - cls.user_id.isnot(None), - # then... - sa.select(User.fullname).where(cls.user_id == User.id).as_scalar(), - ), - ( - # elif... - cls.organization_id.isnot(None), - # then... - sa.select(Organization.title) - .where(cls.organization_id == Organization.id) - .as_scalar(), - ), - else_='', - ) - - @property - def pickername(self) -> str: - if self.user: - return self.user.pickername - if self.organization: - return self.organization.pickername - return self.title - - def roles_for( - self, actor: Optional[User] = None, anchors: Iterable = () - ) -> LazyRoleSet: - if self.owner: - roles = self.owner.roles_for(actor, anchors) - else: - roles = super().roles_for(actor, anchors) - if self.state.PUBLIC: - roles.add('reader') - return roles - - @classmethod - def get(cls, name: str) -> Optional[Profile]: - return cls.query.filter( - sa.func.lower(Profile.name) == sa.func.lower(name) - ).one_or_none() - - @classmethod - def all_public(cls) -> Query: - return cls.query.filter(cls.state.PUBLIC) - - @classmethod - def validate_name_candidate(cls, name: str) -> Optional[str]: - """ - 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_username(name): - return 'invalid' - if len(name) > cls.__name_length__: - return 'long' - existing = ( - cls.query.filter(sa.func.lower(cls.name) == sa.func.lower(name)) - .options( - sa.orm.load_only( - cls.id, cls.uuid, cls.user_id, cls.organization_id, cls.reserved - ) - ) - .one_or_none() - ) - if existing is not None: - if existing.reserved: - return 'reserved' - if existing.user_id: - return 'user' - if existing.organization_id: - return 'org' - return None - - @classmethod - def is_available_name(cls, name: str) -> bool: - return cls.validate_name_candidate(name) is None - - @sa.orm.validates('name') - def validate_name(self, key: str, value: str): - if value.lower() in self.reserved_names or not valid_username(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 - - @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.""" - if old_user.profile is not None and new_user.profile is None: - # New user doesn't have an account (nee profile). Simply transfer ownership - new_user.profile = old_user.profile - elif old_user.profile is not None and new_user.profile is not None: - # Both have accounts. Move everything that refers to old account - done = do_migrate_instances( - old_user.profile, new_user.profile, 'migrate_profile' - ) - if done: - db.session.delete(old_user.profile) - # Do nothing if old_user.profile is None and new_user.profile is not None - - @property - def teams(self) -> List: - if self.organization: - return self.organization.teams - return [] - - @with_roles(call={'owner'}) - @state.transition( - state.NOT_PUBLIC, - state.PUBLIC, - title=__("Make public"), - if_=lambda profile: ( - profile.reserved is False - and profile.is_active - and (profile.user is None or profile.user.features.not_likely_throwaway) - ), - ) - def make_public(self) -> None: - """Make an account public if it is eligible.""" - - @with_roles(call={'owner'}) - @state.transition(state.NOT_PRIVATE, state.PRIVATE, title=__("Make private")) - def make_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() - - def do_delete(self, actor: User) -> bool: - """Delete contents of this account.""" - if self.is_safe_to_delete(): - for membership in self.active_memberships(): - membership = membership.freeze_subject_attribution(actor) - if membership.revoke_on_subject_delete: - membership.revoke(actor=actor) - return True - return False - - @classmethod - def autocomplete(cls, prefix: str) -> List[Profile]: - """Return accounts beginning with the query, for autocomplete.""" - prefix = prefix.strip() - if not prefix: - return [] - tsquery = quote_autocomplete_tsquery(prefix) - return ( - cls.query.options(sa.orm.defer(cls.is_active)) - .join(User) - .filter( - User.state.ACTIVE, - sa.or_( - cls.search_vector.bool_op('@@')(tsquery), - User.search_vector.bool_op('@@')(tsquery), - ), - ) - .union( - cls.query.options(sa.orm.defer(cls.is_active)) - .join(Organization) - .filter( - Organization.state.ACTIVE, - sa.or_( - cls.search_vector.bool_op('@@')(tsquery), - Organization.search_vector.bool_op('@@')(tsquery), - ), - ), - ) - .order_by(cls.name) - .all() - ) - - -add_search_trigger(Profile, 'search_vector') diff --git a/funnel/models/project.py b/funnel/models/project.py index c22e02155..5bf0787bc 100644 --- a/funnel/models/project.py +++ b/funnel/models/project.py @@ -2,44 +2,45 @@ from __future__ import annotations -from typing import Iterable, List, Optional +from collections.abc import Sequence +from pytz import utc from sqlalchemy.orm import attribute_keyed_dict - from werkzeug.utils import cached_property -from pytz import utc - from baseframe import __, localize_timezone from coaster.sqlalchemy import LazyRoleSet, StateManager, with_roles from coaster.utils import LabeledEnum, buid, utcnow from .. import app -from ..typing import OptionalMigratedTables from . import ( BaseScopedNameMixin, + DynamicMapped, Mapped, - MarkdownCompositeDocument, + Model, + Query, TimestampMixin, TimezoneType, TSVectorType, UrlType, UuidMixin, + backref, db, - json_type, + relationship, sa, + types, ) +from .account import Account from .comment import SET_TYPE, Commentset from .helpers import ( RESERVED_NAMES, ImgeeType, + MarkdownCompositeDocument, add_search_trigger, reopen, valid_name, visual_field_delimiter, ) -from .profile import Profile -from .user import User __all__ = ['Project', 'ProjectLocation', 'ProjectRedirect'] @@ -66,63 +67,70 @@ class CFP_STATE(LabeledEnum): # noqa: N801 # --- Models ------------------------------------------------------------------ -class Project(UuidMixin, BaseScopedNameMixin, db.Model): # type: ignore[name-defined] +class Project(UuidMixin, BaseScopedNameMixin, Model): __tablename__ = 'project' - __allow_unmapped__ = True reserved_names = RESERVED_NAMES - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = sa.orm.relationship( - User, - foreign_keys=[user_id], - backref=sa.orm.backref('projects', cascade='all'), - ) - profile_id = sa.Column(sa.Integer, sa.ForeignKey('profile.id'), nullable=False) - profile: Mapped[Profile] = with_roles( - sa.orm.relationship( - Profile, backref=sa.orm.backref('projects', cascade='all', lazy='dynamic') + created_by_id = sa.orm.mapped_column(sa.ForeignKey('account.id'), nullable=False) + created_by: Mapped[Account] = relationship( + Account, + foreign_keys=[created_by_id], + ) + account_id = sa.orm.mapped_column(sa.ForeignKey('account.id'), nullable=False) + account: Mapped[Account] = with_roles( + relationship( + Account, + foreign_keys=[account_id], + backref=backref('projects', cascade='all', lazy='dynamic'), ), read={'all'}, - # If account grants an 'admin' role, make it 'profile_admin' here - grants_via={None: {'admin': 'profile_admin'}}, - # `profile` only appears in the 'primary' dataset. It must not be included in + # If account grants an 'admin' role, make it 'account_admin' here + grants_via={ + None: { + 'admin': 'account_admin', + 'follower': 'account_participant', + } + }, + # `account` only appears in the 'primary' dataset. It must not be included in # 'related' or 'without_parent' as it is the parent datasets={'primary'}, ) - parent: Mapped[Profile] = sa.orm.synonym('profile') + parent: Mapped[Account] = sa.orm.synonym('account') tagline: Mapped[str] = with_roles( - sa.Column(sa.Unicode(250), nullable=False), + sa.orm.mapped_column(sa.Unicode(250), nullable=False), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) - description = with_roles( - MarkdownCompositeDocument.create('description', default='', nullable=False), - read={'all'}, - ) - instructions = with_roles( - MarkdownCompositeDocument.create('instructions', default='', nullable=True), - read={'all'}, + description, description_text, description_html = MarkdownCompositeDocument.create( + 'description', default='', nullable=False ) + with_roles(description, read={'all'}) + ( + instructions, + instructions_text, + instructions_html, + ) = MarkdownCompositeDocument.create('instructions', default='', nullable=True) + with_roles(instructions, read={'all'}) location = with_roles( - sa.Column(sa.Unicode(50), default='', nullable=True), + sa.orm.mapped_column(sa.Unicode(50), default='', nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) - parsed_location = sa.Column(json_type, nullable=False, server_default='{}') + parsed_location: Mapped[types.jsonb_dict] website = with_roles( - sa.Column(UrlType, nullable=True), + sa.orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) timezone = with_roles( - sa.Column(TimezoneType(backend='pytz'), nullable=False, default=utc), + sa.orm.mapped_column(TimezoneType(backend='pytz'), nullable=False, default=utc), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) - _state = sa.Column( + _state = sa.orm.mapped_column( 'state', sa.Integer, StateManager.check_constraint('state', PROJECT_STATE), @@ -133,7 +141,7 @@ class Project(UuidMixin, BaseScopedNameMixin, db.Model): # type: ignore[name-de state = with_roles( StateManager('_state', PROJECT_STATE, doc="Project state"), call={'all'} ) - _cfp_state = sa.Column( + _cfp_state = sa.orm.mapped_column( 'cfp_state', sa.Integer, StateManager.check_constraint('cfp_state', CFP_STATE), @@ -146,55 +154,61 @@ class Project(UuidMixin, BaseScopedNameMixin, db.Model): # type: ignore[name-de ) #: Audit timestamp to detect re-publishing to re-surface a project - first_published_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + first_published_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Timestamp of when this project was most recently published published_at = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'all'}, write={'promoter'}, datasets={'primary', 'without_parent', 'related'}, ) #: Optional start time for schedule, cached from column property schedule_start_at start_at = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'all'}, write={'editor'}, datasets={'primary', 'without_parent', 'related'}, ) #: Optional end time for schedule, cached from column property schedule_end_at end_at = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'all'}, write={'editor'}, datasets={'primary', 'without_parent', 'related'}, ) - cfp_start_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True, index=True) - cfp_end_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True, index=True) + cfp_start_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True, index=True + ) + cfp_end_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True, index=True + ) bg_image = with_roles( - sa.Column(ImgeeType, nullable=True), + sa.orm.mapped_column(ImgeeType, nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) allow_rsvp: Mapped[bool] = with_roles( - sa.Column(sa.Boolean, default=True, nullable=False), + sa.orm.mapped_column(sa.Boolean, default=True, nullable=False), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) - buy_tickets_url: Mapped[Optional[str]] = with_roles( - sa.Column(UrlType, nullable=True), + buy_tickets_url: Mapped[str | None] = with_roles( + sa.orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) banner_video_url = with_roles( - sa.Column(UrlType, nullable=True), + sa.orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) - boxoffice_data = with_roles( - sa.Column(json_type, nullable=False, server_default='{}'), + boxoffice_data: Mapped[types.jsonb_dict] = with_roles( + sa.orm.mapped_column(), # This is an attribute, but we deliberately use `call` instead of `read` to # block this from dictionary enumeration. FIXME: Break up this dictionary into # individual columns with `all` access for ticket embed id and `promoter` @@ -202,13 +216,17 @@ class Project(UuidMixin, BaseScopedNameMixin, db.Model): # type: ignore[name-de call={'all'}, ) - hasjob_embed_url = with_roles(sa.Column(UrlType, nullable=True), read={'all'}) - hasjob_embed_limit = with_roles(sa.Column(sa.Integer, default=8), read={'all'}) + hasjob_embed_url = with_roles( + sa.orm.mapped_column(UrlType, nullable=True), read={'all'} + ) + hasjob_embed_limit = with_roles( + sa.orm.mapped_column(sa.Integer, default=8), read={'all'} + ) - commentset_id = sa.Column( + commentset_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id'), nullable=False ) - commentset: Mapped[Commentset] = sa.orm.relationship( + commentset: Mapped[Commentset] = relationship( Commentset, uselist=False, cascade='all', @@ -216,76 +234,89 @@ class Project(UuidMixin, BaseScopedNameMixin, db.Model): # type: ignore[name-de back_populates='project', ) - parent_id = sa.Column( + parent_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='SET NULL'), nullable=True ) - parent_project: Mapped[Optional[Project]] = sa.orm.relationship( + parent_project: Mapped[Project | None] = relationship( 'Project', remote_side='Project.id', backref='subprojects' ) #: Featured project flag. This can only be set by website editors, not #: project editors or account admins. site_featured = with_roles( - sa.Column(sa.Boolean, default=False, nullable=False), + sa.orm.mapped_column(sa.Boolean, default=False, nullable=False), read={'all'}, write={'site_editor'}, datasets={'primary', 'without_parent'}, ) - #: Revision number maintained by SQLAlchemy, used for vCal files, starting at 1 - revisionid = with_roles(sa.Column(sa.Integer, nullable=False), read={'all'}) - - search_vector: Mapped[TSVectorType] = sa.orm.deferred( - sa.Column( - TSVectorType( - 'name', - 'title', - 'description_text', - 'instructions_text', - 'location', - weights={ - 'name': 'A', - 'title': 'A', - 'description_text': 'B', - 'instructions_text': 'B', - 'location': 'C', - }, - regconfig='english', - hltext=lambda: sa.func.concat_ws( - visual_field_delimiter, - Project.title, - Project.location, - Project.description_html, - Project.instructions_html, - ), - ), - nullable=False, - ) + livestream_urls = with_roles( + sa.orm.mapped_column( + sa.ARRAY(sa.UnicodeText, dimensions=1), + server_default=sa.text("'{}'::text[]"), + ), + read={'all'}, + datasets={'primary', 'without_parent'}, ) - livestream_urls = with_roles( - sa.Column(sa.ARRAY(sa.UnicodeText, dimensions=1), server_default='{}'), + is_restricted_video: Mapped[bool] = with_roles( + sa.orm.mapped_column(sa.Boolean, default=False, nullable=False), read={'all'}, datasets={'primary', 'without_parent'}, ) + #: Revision number maintained by SQLAlchemy, used for vCal files, starting at 1 + revisionid = with_roles( + sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + ) + + search_vector: Mapped[TSVectorType] = sa.orm.mapped_column( + TSVectorType( + 'name', + 'title', + 'description_text', + 'instructions_text', + 'location', + weights={ + 'name': 'A', + 'title': 'A', + 'description_text': 'B', + 'instructions_text': 'B', + 'location': 'C', + }, + regconfig='english', + hltext=lambda: sa.func.concat_ws( + visual_field_delimiter, + Project.title, + Project.location, + Project.description_html, + Project.instructions_html, + ), + ), + nullable=False, + deferred=True, + ) + + # Relationships + primary_venue: Mapped[Venue | None] = relationship() + __table_args__ = ( - sa.UniqueConstraint('profile_id', 'name'), + sa.UniqueConstraint('account_id', 'name'), sa.Index('ix_project_search_vector', 'search_vector', postgresql_using='gin'), sa.CheckConstraint( - sa.or_( # type: ignore[arg-type] + sa.or_( sa.and_(start_at.is_(None), end_at.is_(None)), - sa.and_(start_at.isnot(None), end_at.isnot(None), end_at > start_at), + sa.and_(start_at.is_not(None), end_at.is_not(None), end_at > start_at), ), 'project_start_at_end_at_check', ), sa.CheckConstraint( - sa.or_( # type: ignore[arg-type] + sa.or_( sa.and_(cfp_start_at.is_(None), cfp_end_at.is_(None)), - sa.and_(cfp_start_at.isnot(None), cfp_end_at.is_(None)), + sa.and_(cfp_start_at.is_not(None), cfp_end_at.is_(None)), sa.and_( - cfp_start_at.isnot(None), - cfp_end_at.isnot(None), + cfp_start_at.is_not(None), + cfp_end_at.is_not(None), cfp_end_at > cfp_start_at, ), ), @@ -391,7 +422,7 @@ class Project(UuidMixin, BaseScopedNameMixin, db.Model): # type: ignore[name-de cfp_state.NONE, lambda project: project.instructions_html != '', lambda project: sa.and_( - project.instructions_html.isnot(None), project.instructions_html != '' + project.instructions_html.is_not(None), project.instructions_html != '' ), label=('draft', __("Draft")), ) @@ -410,7 +441,7 @@ class Project(UuidMixin, BaseScopedNameMixin, db.Model): # type: ignore[name-de lambda project: project.cfp_end_at is not None and utcnow() >= project.cfp_end_at, lambda project: sa.and_( - project.cfp_end_at.isnot(None), sa.func.utcnow() >= project.cfp_end_at + project.cfp_end_at.is_not(None), sa.func.utcnow() >= project.cfp_end_at ), label=('expired', __("Expired")), ) @@ -431,10 +462,10 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.commentset = Commentset(settype=SET_TYPE.PROJECT) # Add the creator as editor and promoter - new_membership = ProjectCrewMembership( + new_membership = ProjectMembership( parent=self, - user=self.user, - granted_by=self.user, + member=self.created_by, + granted_by=self.created_by, is_editor=True, is_promoter=True, ) @@ -442,7 +473,15 @@ def __init__(self, **kwargs) -> None: def __repr__(self) -> str: """Represent :class:`Project` as a string.""" - return f'' + return f'' + + def __str__(self) -> str: + return self.joined_title + + def __format__(self, format_spec: str) -> str: + if not format_spec: + return self.joined_title + return self.joined_title.__format__(format_spec) @with_roles(call={'editor'}) @cfp_state.transition( @@ -453,6 +492,7 @@ def __repr__(self) -> str: type='success', ) def open_cfp(self): + """Change state to accept submissions.""" # If closing date is in the past, remove it if self.cfp_end_at is not None and self.cfp_end_at <= utcnow(): self.cfp_end_at = None @@ -469,7 +509,7 @@ def open_cfp(self): type='success', ) def close_cfp(self): - pass + """Change state to not accept submissions.""" @with_roles(call={'editor'}) @state.transition( @@ -497,14 +537,14 @@ def publish(self) -> bool: type='success', ) def withdraw(self): - pass + """Withdraw a project.""" @property def title_inline(self) -> str: """Suffix a colon if the title does not end in ASCII sentence punctuation.""" if self.title and self.tagline: # pylint: disable=unsubscriptable-object - if not self.title[-1] in ('?', '!', ':', ';', '.', ','): + if self.title[-1] not in ('?', '!', ':', ';', '.', ','): return self.title + ':' return self.title @@ -513,18 +553,18 @@ def title_inline(self) -> str: @property def title_suffix(self) -> str: """ - Return the profile's title if the project's title doesn't derive from it. + Return the account's title if the project's title doesn't derive from it. Used in HTML title tags to render {{ project }} - {{ suffix }}. """ - if not self.title.startswith(self.parent.title): - return self.profile.title + if not self.title.startswith(self.account.title): + return self.account.title return '' with_roles(title_suffix, read={'all'}) @property - def title_parts(self) -> List[str]: + def title_parts(self) -> list[str]: """ Return the hierarchy of titles of this project. @@ -537,7 +577,7 @@ def title_parts(self) -> List[str]: """ if self.short_title == self.title: # Project title does not derive from account title, so use both - return [self.profile.title, self.title] + return [self.account.title, self.title] # Project title extends account title, so account title is not needed return [self.title] @@ -606,12 +646,12 @@ def datelocation(self) -> str: # def delete(self): # pass - @sa.orm.validates('name', 'profile') + @sa.orm.validates('name', 'account') def _validate_and_create_redirect(self, key, value): # TODO: When labels, venues and other resources are relocated from project to - # account, this validator can no longer watch for `profile` change. We'll need a + # account, this validator can no longer watch for `account` change. We'll need a # more elaborate transfer mechanism that remaps resources to equivalent ones in - # the new `profile`. + # the new `account`. if key == 'name': value = value.strip() if value is not None else None if not value or (key == 'name' and not valid_name(value)): @@ -661,7 +701,7 @@ def update_schedule_timestamps(self): self.end_at = self.schedule_end_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) # https://github.com/hasgeek/funnel/pull/220#discussion_r168718052 @@ -673,51 +713,51 @@ def is_safe_to_delete(self) -> bool: return self.proposals.count() == 0 @classmethod - def order_by_date(cls): + def order_by_date(cls) -> sa.Case: """ Return an order by clause for the project's start_at or published_at. param bool desc: Use descending order (default True) """ clause = sa.case( - (cls.start_at.isnot(None), cls.start_at), + (cls.start_at.is_not(None), cls.start_at), else_=cls.published_at, ) return clause @classmethod - def all_unsorted(cls): + def all_unsorted(cls) -> Query[Project]: """Return query of all published projects, without ordering criteria.""" return ( - cls.query.join(Profile) + cls.query.join(Account, Project.account) .outerjoin(Venue) - .filter(cls.state.PUBLISHED, Profile.is_verified.is_(True)) + .filter(cls.state.PUBLISHED, Account.is_verified.is_(True)) ) @classmethod - def all(cls): # noqa: A003 + def all(cls) -> Query[Project]: # noqa: A003 """Return all published projects, ordered by date.""" return cls.all_unsorted().order_by(cls.order_by_date()) # The base class offers `get(parent, name)`. We accept f'{parent}/{name}' here for # convenience as this is only used in shell access. @classmethod - def get(cls, profile_project): # pylint: disable=arguments-differ - """Get a project by its URL slug in the form ``/``.""" - profile_name, project_name = profile_project.split('/') + def get( # type: ignore[override] # pylint: disable=arguments-differ + cls, account_project: str + ) -> Project | None: + """Get a project by its URL slug in the form ``/``.""" + account_name, project_name = account_project.split('/') return ( - cls.query.join(Profile) - .filter(Profile.name == profile_name, Project.name == project_name) + cls.query.join(Account, Project.account) + .filter(Account.name_is(account_name), Project.name == project_name) .one_or_none() ) @classmethod - def migrate_profile( # type: ignore[return] - cls, old_profile: Profile, new_profile: Profile - ) -> OptionalMigratedTables: + def migrate_account(cls, old_account: Account, new_account: Account) -> None: """Migrate from one account to another when merging users.""" - names = {project.name for project in new_profile.projects} - for project in old_profile.projects: + names = {project.name for project in new_account.projects} + for project in old_account.projects: if project.name in names: app.logger.warning( "Project %r had a conflicting name in account migration," @@ -725,65 +765,68 @@ def migrate_profile( # type: ignore[return] project, ) project.name += '-' + buid() - project.profile = new_profile + project.account = new_account add_search_trigger(Project, 'search_vector') -@reopen(Profile) -class __Profile: +@reopen(Account) +class __Account: id: Mapped[int] # noqa: A003 - listed_projects = sa.orm.relationship( + listed_projects: DynamicMapped[Project] = relationship( Project, lazy='dynamic', primaryjoin=sa.and_( - Profile.id == Project.profile_id, + Account.id == Project.account_id, Project.state.PUBLISHED, ), viewonly=True, ) - draft_projects = sa.orm.relationship( + draft_projects: DynamicMapped[Project] = relationship( Project, lazy='dynamic', primaryjoin=sa.and_( - Profile.id == Project.profile_id, + Account.id == Project.account_id, sa.or_(Project.state.DRAFT, Project.cfp_state.DRAFT), ), viewonly=True, ) projects_by_name = with_roles( - sa.orm.relationship( - Project, collection_class=attribute_keyed_dict('name'), viewonly=True + relationship( + Project, + foreign_keys=[Project.account_id], + collection_class=attribute_keyed_dict('name'), + viewonly=True, ), read={'all'}, ) - def draft_projects_for(self, user: Optional[User]) -> List[Project]: + def draft_projects_for(self, user: Account | None) -> list[Project]: if user is not None: return [ membership.project - for membership in user.projects_as_crew_active_memberships.join(Project) - .join(Profile) - .filter( + for membership in user.projects_as_crew_active_memberships.join( + Project + ).filter( # Project is attached to this account - Project.profile_id == self.id, + Project.account_id == self.id, # Project is in draft state OR has a draft call for proposals sa.or_(Project.state.DRAFT, Project.cfp_state.DRAFT), ) ] return [] - def unscheduled_projects_for(self, user: Optional[User]) -> List[Project]: + def unscheduled_projects_for(self, user: Account | None) -> list[Project]: if user is not None: return [ membership.project - for membership in user.projects_as_crew_active_memberships.join(Project) - .join(Profile) - .filter( + for membership in user.projects_as_crew_active_memberships.join( + Project + ).filter( # Project is attached to this account - Project.profile_id == self.id, + Project.account_id == self.id, # Project is in draft state OR has a draft call for proposals sa.or_(Project.state.PUBLISHED_WITHOUT_SESSIONS), ) @@ -798,96 +841,102 @@ def published_project_count(self) -> int: ) -class ProjectRedirect(TimestampMixin, db.Model): # type: ignore[name-defined] +class ProjectRedirect(TimestampMixin, Model): __tablename__ = 'project_redirect' - __allow_unmapped__ = True - profile_id = sa.Column( - sa.Integer, sa.ForeignKey('profile.id'), nullable=False, primary_key=True + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False, primary_key=True ) - profile: Mapped[Profile] = sa.orm.relationship( - Profile, backref=sa.orm.backref('project_redirects', cascade='all') + account: Mapped[Account] = relationship( + Account, backref=backref('project_redirects', cascade='all') + ) + parent: Mapped[Account] = sa.orm.synonym('account') + name: Mapped[str] = sa.orm.mapped_column( + sa.Unicode(250), nullable=False, primary_key=True ) - parent: Mapped[Profile] = sa.orm.synonym('profile') - name = sa.Column(sa.Unicode(250), nullable=False, primary_key=True) - project_id = sa.Column( + project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='SET NULL'), nullable=True ) - project: Mapped[Project] = sa.orm.relationship(Project, backref='redirects') + project: Mapped[Project] = relationship(Project, backref='redirects') def __repr__(self) -> str: """Represent :class:`ProjectRedirect` as a string.""" if not self.project: - return f'' + return f'' return ( - f'' + f'' ) def redirect_view_args(self): if self.project: - return {'profile': self.profile.name, 'project': self.project.name} + return {'account': self.account.urlname, 'project': self.project.name} return {} @classmethod - def add(cls, project, profile=None, name=None): + def add( + cls, + project: Project, + account: Account | None = None, + name: str | None = None, + ) -> ProjectRedirect: """ - Add a project redirect in a given profile. + Add a project redirect in a given account. :param project: The project to create a redirect for - :param profile: The profile to place the redirect in, defaulting to existing + :param account: The account to place the redirect in, defaulting to existing :param str name: Name to redirect, defaulting to project's existing name Typical use is when a project is renamed, to create a redirect from its previous - name, or when it's moved between projects, to create a redirect from previous - project. + name, or when it's moved between accounts, to create a redirect from previous + account. """ - if profile is None: - profile = project.profile + if account is None: + account = project.account if name is None: name = project.name - redirect = ProjectRedirect.query.get((profile.id, name)) + redirect = cls.query.get((account.id, name)) if redirect is None: - redirect = ProjectRedirect(profile=profile, name=name, project=project) + redirect = cls(account=account, name=name, project=project) db.session.add(redirect) else: redirect.project = project return redirect @classmethod - def migrate_profile( # type: ignore[return] - cls, old_profile: Profile, new_profile: Profile - ) -> OptionalMigratedTables: + def migrate_account(cls, old_account: Account, new_account: Account) -> None: """ - Discard redirects when migrating profiles. + Transfer project redirects when migrating accounts, discarding dupe names. - Since there is no profile redirect, all project redirects will also be - unreachable and are no longer relevant. + Since there is no account redirect, all project redirects will also be + unreachable after this transfer, unless the new account is renamed to take the + old account's name. """ - names = {pr.name for pr in new_profile.project_redirects} - for pr in old_profile.project_redirects: + names = {pr.name for pr in new_account.project_redirects} + for pr in old_account.project_redirects: if pr.name not in names: - pr.profile = new_profile + pr.account = new_account else: # Discard project redirect since the name is already taken by another # redirect in the new account db.session.delete(pr) -class ProjectLocation(TimestampMixin, db.Model): # type: ignore[name-defined] +class ProjectLocation(TimestampMixin, Model): __tablename__ = 'project_location' - __allow_unmapped__ = True #: Project we are tagging - project_id = sa.Column( + project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), primary_key=True, nullable=False ) - project: Mapped[Project] = sa.orm.relationship( - Project, backref=sa.orm.backref('locations', cascade='all') + project: Mapped[Project] = relationship( + Project, backref=backref('locations', cascade='all') ) #: Geonameid for this project - geonameid = sa.Column(sa.Integer, primary_key=True, nullable=False, index=True) - primary = sa.Column(sa.Boolean, default=True, nullable=False) + geonameid = sa.orm.mapped_column( + sa.Integer, primary_key=True, nullable=False, index=True + ) + primary = sa.orm.mapped_column(sa.Boolean, default=True, nullable=False) def __repr__(self) -> str: """Represent :class:`ProjectLocation` as a string.""" @@ -900,12 +949,12 @@ def __repr__(self) -> str: @reopen(Commentset) class __Commentset: project = with_roles( - sa.orm.relationship(Project, uselist=False, back_populates='commentset'), + relationship(Project, uselist=False, back_populates='commentset'), grants_via={None: {'editor': 'document_subscriber'}}, ) # Tail imports # pylint: disable=wrong-import-position -from .project_membership import ProjectCrewMembership # isort:skip +from .project_membership import ProjectMembership # isort:skip from .venue import Venue # isort:skip # skipcq: FLK-E402 diff --git a/funnel/models/project_membership.py b/funnel/models/project_membership.py index 035fee32f..fda29e6b3 100644 --- a/funnel/models/project_membership.py +++ b/funnel/models/project_membership.py @@ -2,23 +2,20 @@ from __future__ import annotations -from typing import Dict, Set, Union -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - from werkzeug.utils import cached_property -from coaster.sqlalchemy import DynamicAssociationProxy, with_roles +from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles -from . import Mapped, db, declared_attr, sa +from . import DynamicMapped, Mapped, Model, backref, declared_attr, relationship, sa +from .account import Account from .helpers import reopen from .membership_mixin import ImmutableUserMembershipMixin from .project import Project -from .user import User -__all__ = ['ProjectCrewMembership', 'project_child_role_map'] +__all__ = ['ProjectMembership', 'project_child_role_map', 'project_child_role_set'] #: Roles in a project and their remapped names in objects attached to a project -project_child_role_map: Dict[str, str] = { +project_child_role_map: dict[str, str] = { 'editor': 'project_editor', 'promoter': 'project_promoter', 'usher': 'project_usher', @@ -27,38 +24,38 @@ 'reader': 'reader', } -#: ProjectCrewMembership maps project's `profile_admin` role to membership's `editor` +#: A model that is indirectly under a project needs the role names without remapping +project_child_role_set: set[str] = set(project_child_role_map.values()) + +#: ProjectMembership maps project's `account_admin` role to membership's `editor` #: role in addition to the recurring role grant map -project_membership_role_map: Dict[str, Union[str, Set[str]]] = { - 'profile_admin': {'profile_admin', 'editor'} +project_membership_role_map: dict[str, str | set[str]] = { + 'account_admin': {'account_admin', 'editor'} } project_membership_role_map.update(project_child_role_map) -class ProjectCrewMembership( - ImmutableUserMembershipMixin, - db.Model, # type: ignore[name-defined] -): +class ProjectMembership(ImmutableUserMembershipMixin, Model): """Users can be crew members of projects, with specified access rights.""" - __tablename__ = 'project_crew_membership' - __allow_unmapped__ = True + __tablename__ = 'project_membership' #: Legacy data has no granted_by __null_granted_by__ = True #: List of is_role columns in this model - __data_columns__ = ('is_editor', 'is_promoter', 'is_usher') + __data_columns__ = ('is_editor', 'is_promoter', 'is_usher', 'label') __roles__ = { 'all': { 'read': { 'urls', - 'user', + 'member', 'project', 'is_editor', 'is_promoter', 'is_usher', + 'label', } }, 'project_crew': { @@ -81,7 +78,8 @@ class ProjectCrewMembership( 'is_editor', 'is_promoter', 'is_usher', - 'user', + 'label', + 'member', 'project', }, 'without_parent': { @@ -91,7 +89,8 @@ class ProjectCrewMembership( 'is_editor', 'is_promoter', 'is_usher', - 'user', + 'label', + 'member', }, 'related': { 'urls', @@ -100,16 +99,17 @@ class ProjectCrewMembership( 'is_editor', 'is_promoter', 'is_usher', + 'label', }, } - project_id: Mapped[int] = sa.Column( + project_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False ) project: Mapped[Project] = with_roles( - sa.orm.relationship( + relationship( Project, - backref=sa.orm.backref( + backref=backref( 'crew_memberships', lazy='dynamic', cascade='all', @@ -125,24 +125,41 @@ class ProjectCrewMembership( # Project crew roles (at least one must be True): #: Editors can edit all common and editorial details of an event - is_editor: Mapped[bool] = sa.Column(sa.Boolean, nullable=False, default=False) + is_editor: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) #: Promoters are responsible for promotion and have write access #: to common details plus read access to everything else. Unlike #: editors, they cannot edit the schedule - is_promoter: Mapped[bool] = sa.Column(sa.Boolean, nullable=False, default=False) + is_promoter: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) #: Ushers help participants find their way around an event and have #: the ability to scan badges at the door - is_usher: Mapped[bool] = sa.Column(sa.Boolean, nullable=False, default=False) + is_usher: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + + #: Optional label, indicating the member's role in the project + label = immutable( + sa.orm.mapped_column( + sa.Unicode, + sa.CheckConstraint( + "label <> ''", name='project_crew_membership_label_check' + ), + nullable=True, + ) + ) @declared_attr.directive @classmethod - def __table_args__(cls) -> tuple: # type: ignore[override] + def __table_args__(cls) -> tuple: """Table arguments.""" args = list(super().__table_args__) kwargs = args.pop(-1) if args and isinstance(args[-1], dict) else None args.append( sa.CheckConstraint( - sa.or_( # type: ignore[arg-type] + sa.or_( cls.is_editor.is_(True), cls.is_promoter.is_(True), cls.is_usher.is_(True), @@ -155,7 +172,7 @@ def __table_args__(cls) -> tuple: # type: ignore[override] return tuple(args) @cached_property - def offered_roles(self) -> Set[str]: + def offered_roles(self) -> set[str]: """Roles offered by this membership record.""" roles = {'crew', 'participant'} if self.is_editor: @@ -170,88 +187,92 @@ def offered_roles(self) -> Set[str]: # Project relationships: all crew, vs specific roles @reopen(Project) class __Project: - active_crew_memberships = with_roles( - sa.orm.relationship( - ProjectCrewMembership, + active_crew_memberships: DynamicMapped[ProjectMembership] = with_roles( + relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.project_id == Project.id, - ProjectCrewMembership.is_active, # type: ignore[arg-type] + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, ), viewonly=True, ), - grants_via={'user': {'editor', 'promoter', 'usher', 'participant', 'crew'}}, + grants_via={'member': {'editor', 'promoter', 'usher', 'participant', 'crew'}}, ) - active_editor_memberships = sa.orm.relationship( - ProjectCrewMembership, + active_editor_memberships: DynamicMapped[ProjectMembership] = relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.project_id == Project.id, - ProjectCrewMembership.is_active, # type: ignore[arg-type] - ProjectCrewMembership.is_editor.is_(True), + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, + ProjectMembership.is_editor.is_(True), ), viewonly=True, ) - active_promoter_memberships = sa.orm.relationship( - ProjectCrewMembership, + active_promoter_memberships: DynamicMapped[ProjectMembership] = relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.project_id == Project.id, - ProjectCrewMembership.is_active, # type: ignore[arg-type] - ProjectCrewMembership.is_promoter.is_(True), + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, + ProjectMembership.is_promoter.is_(True), ), viewonly=True, ) - active_usher_memberships = sa.orm.relationship( - ProjectCrewMembership, + active_usher_memberships: DynamicMapped[ProjectMembership] = relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.project_id == Project.id, - ProjectCrewMembership.is_active, # type: ignore[arg-type] - ProjectCrewMembership.is_usher.is_(True), + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, + ProjectMembership.is_usher.is_(True), ), viewonly=True, ) - crew = DynamicAssociationProxy('active_crew_memberships', 'user') - editors = DynamicAssociationProxy('active_editor_memberships', 'user') - promoters = DynamicAssociationProxy('active_promoter_memberships', 'user') - ushers = DynamicAssociationProxy('active_usher_memberships', 'user') + crew = DynamicAssociationProxy('active_crew_memberships', 'member') + editors = DynamicAssociationProxy('active_editor_memberships', 'member') + promoters = DynamicAssociationProxy('active_promoter_memberships', 'member') + ushers = DynamicAssociationProxy('active_usher_memberships', 'member') # Similarly for users (add as needs come up) -@reopen(User) -class __User: +@reopen(Account) +class __Account: # pylint: disable=invalid-unary-operand-type # This relationship is only useful to check if the user has ever been a crew member. # Most operations will want to use one of the active membership relationships. - projects_as_crew_memberships = sa.orm.relationship( - ProjectCrewMembership, + projects_as_crew_memberships: DynamicMapped[ProjectMembership] = relationship( + ProjectMembership, lazy='dynamic', - foreign_keys=[ProjectCrewMembership.user_id], + foreign_keys=[ProjectMembership.member_id], viewonly=True, ) # This is used to determine if it is safe to purge the subject's database record - projects_as_crew_noninvite_memberships = sa.orm.relationship( - ProjectCrewMembership, + projects_as_crew_noninvite_memberships: DynamicMapped[ + ProjectMembership + ] = relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.user_id == User.id, - ~ProjectCrewMembership.is_invite, # type: ignore[operator] + ProjectMembership.member_id == Account.id, + ~ProjectMembership.is_invite, ), viewonly=True, ) - projects_as_crew_active_memberships = sa.orm.relationship( - ProjectCrewMembership, + projects_as_crew_active_memberships: DynamicMapped[ + ProjectMembership + ] = relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.user_id == User.id, - ProjectCrewMembership.is_active, # type: ignore[arg-type] + ProjectMembership.member_id == Account.id, + ProjectMembership.is_active, ), viewonly=True, ) @@ -260,13 +281,15 @@ class __User: 'projects_as_crew_active_memberships', 'project' ) - projects_as_editor_active_memberships = sa.orm.relationship( - ProjectCrewMembership, + projects_as_editor_active_memberships: DynamicMapped[ + ProjectMembership + ] = relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.user_id == User.id, - ProjectCrewMembership.is_active, # type: ignore[arg-type] - ProjectCrewMembership.is_editor.is_(True), + ProjectMembership.member_id == Account.id, + ProjectMembership.is_active, + ProjectMembership.is_editor.is_(True), ), viewonly=True, ) @@ -276,5 +299,5 @@ class __User: ) -User.__active_membership_attrs__.add('projects_as_crew_active_memberships') -User.__noninvite_membership_attrs__.add('projects_as_crew_noninvite_memberships') +Account.__active_membership_attrs__.add('projects_as_crew_active_memberships') +Account.__noninvite_membership_attrs__.add('projects_as_crew_noninvite_memberships') diff --git a/funnel/models/proposal.py b/funnel/models/proposal.py index ee149d297..3f504f63f 100644 --- a/funnel/models/proposal.py +++ b/funnel/models/proposal.py @@ -2,9 +2,8 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime as datetime_type -from typing import Iterable, Optional -from uuid import UUID # noqa: F401 # pylint: disable=unused-import from baseframe import __ from baseframe.filters import preview @@ -15,18 +14,26 @@ BaseMixin, BaseScopedIdNameMixin, Mapped, - MarkdownCompositeDocument, + Model, + Query, TSVectorType, UuidMixin, + backref, db, + relationship, sa, ) +from .account import Account from .comment import SET_TYPE, Commentset -from .helpers import add_search_trigger, reopen, visual_field_delimiter +from .helpers import ( + MarkdownCompositeDocument, + add_search_trigger, + reopen, + visual_field_delimiter, +) from .project import Project from .project_membership import project_child_role_map from .reorder_mixin import ReorderMixin -from .user import User from .video_mixin import VideoMixin __all__ = ['PROPOSAL_STATE', 'Proposal', 'ProposalSuuidRedirect'] @@ -112,30 +119,27 @@ class PROPOSAL_STATE(LabeledEnum): # noqa: N801 class Proposal( # type: ignore[misc] - UuidMixin, - BaseScopedIdNameMixin, - VideoMixin, - ReorderMixin, - db.Model, # type: ignore[name-defined] + UuidMixin, BaseScopedIdNameMixin, VideoMixin, ReorderMixin, Model ): __tablename__ = 'proposal' - __allow_unmapped__ = True - - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user = with_roles( - sa.orm.relationship( - User, - foreign_keys=[user_id], - backref=sa.orm.backref('created_proposals', cascade='all', lazy='dynamic'), + + created_by_id = sa.orm.mapped_column(sa.ForeignKey('account.id'), nullable=False) + created_by = with_roles( + relationship( + Account, + foreign_keys=[created_by_id], + backref=backref('created_proposals', cascade='all', lazy='dynamic'), ), grants={'creator', 'participant'}, ) - project_id = sa.Column(sa.Integer, sa.ForeignKey('project.id'), nullable=False) + project_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('project.id'), nullable=False + ) project: Mapped[Project] = with_roles( - sa.orm.relationship( + relationship( Project, foreign_keys=[project_id], - backref=sa.orm.backref( + backref=backref( 'proposals', cascade='all', lazy='dynamic', order_by='Proposal.url_id' ), ), @@ -158,7 +162,7 @@ class Proposal( # type: ignore[misc] # TODO: Stand-in for `submitted_at` until proposals have a workflow-driven datetime datetime: Mapped[datetime_type] = sa.orm.synonym('created_at') - _state = sa.Column( + _state = sa.orm.mapped_column( 'state', sa.Integer, StateManager.check_constraint('state', PROPOSAL_STATE), @@ -167,10 +171,10 @@ class Proposal( # type: ignore[misc] ) state = StateManager('_state', PROPOSAL_STATE, doc="Current state of the proposal") - commentset_id = sa.Column( + commentset_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id'), nullable=False ) - commentset: Mapped[Commentset] = sa.orm.relationship( + commentset: Mapped[Commentset] = relationship( Commentset, uselist=False, lazy='joined', @@ -179,37 +183,40 @@ class Proposal( # type: ignore[misc] back_populates='proposal', ) - body = MarkdownCompositeDocument.create('body', nullable=False, default='') - description = sa.Column(sa.Unicode, nullable=False, default='') - custom_description = sa.Column(sa.Boolean, nullable=False, default=False) - template = sa.Column(sa.Boolean, nullable=False, default=False) - featured = sa.Column(sa.Boolean, nullable=False, default=False) + body, body_text, body_html = MarkdownCompositeDocument.create( + 'body', nullable=False, default='' + ) + description = sa.orm.mapped_column(sa.Unicode, nullable=False, default='') + custom_description = sa.orm.mapped_column(sa.Boolean, nullable=False, default=False) + template = sa.orm.mapped_column(sa.Boolean, nullable=False, default=False) + featured = sa.orm.mapped_column(sa.Boolean, nullable=False, default=False) - edited_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) + edited_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) #: Revision number maintained by SQLAlchemy, starting at 1 - revisionid = with_roles(sa.Column(sa.Integer, nullable=False), read={'all'}) + revisionid = with_roles( + sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + ) - search_vector: Mapped[TSVectorType] = sa.orm.deferred( - sa.Column( - TSVectorType( - 'title', - 'description', - 'body_text', - weights={ - 'title': 'A', - 'description': 'B', - 'body_text': 'B', - }, - regconfig='english', - hltext=lambda: sa.func.concat_ws( - visual_field_delimiter, - Proposal.title, - Proposal.body_html, - ), + search_vector: Mapped[TSVectorType] = sa.orm.mapped_column( + TSVectorType( + 'title', + 'description', + 'body_text', + weights={ + 'title': 'A', + 'description': 'B', + 'body_text': 'B', + }, + regconfig='english', + hltext=lambda: sa.func.concat_ws( + visual_field_delimiter, + Proposal.title, + Proposal.body_html, ), - nullable=False, - ) + ), + nullable=False, + deferred=True, ) __table_args__ = ( @@ -224,12 +231,13 @@ class Proposal( # type: ignore[misc] __roles__ = { 'all': { 'read': { + 'absolute_url', # From UrlForMixin 'urls', 'uuid_b58', 'url_name_uuid_b58', 'title', 'body', - 'user', + 'created_by', 'first_user', 'session', 'project', @@ -238,7 +246,11 @@ class Proposal( # type: ignore[misc] 'call': {'url_for', 'state', 'commentset', 'views', 'getprev', 'getnext'}, }, 'project_editor': { - 'call': {'reorder_item', 'reorder_before', 'reorder_after'}, + 'call': { + 'reorder_item', + 'reorder_before', + 'reorder_after', + }, }, } @@ -249,7 +261,7 @@ class Proposal( # type: ignore[misc] 'url_name_uuid_b58', 'title', 'body', - 'user', + 'created_by', 'first_user', 'session', 'project', @@ -260,7 +272,7 @@ class Proposal( # type: ignore[misc] 'url_name_uuid_b58', 'title', 'body', - 'user', + 'created_by', 'first_user', 'session', }, @@ -270,18 +282,28 @@ class Proposal( # type: ignore[misc] def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.commentset = Commentset(settype=SET_TYPE.PROPOSAL) - # Assume self.user is set. Fail if not. + # Assume self.created_by is set. Fail if not. db.session.add( - ProposalMembership(proposal=self, user=self.user, granted_by=self.user) + ProposalMembership( + proposal=self, member=self.created_by, granted_by=self.created_by + ) ) def __repr__(self) -> str: """Represent :class:`Proposal` as a string.""" return ( f'' + f' by "{self.created_by.fullname}">' ) + def __str__(self) -> str: + return self.title + + def __format__(self, format_spec: str) -> str: + if not format_spec: + return self.title + return self.title.__format__(format_spec) + # State transitions state.add_conditional_state( 'SCHEDULED', @@ -385,7 +407,7 @@ def cancel(self): state.CANCELLED, state.SUBMITTED, title=__("Undo cancel"), - message=__("This proposal's cancellation has been reversed"), + message=__("This proposal’s cancellation has been reversed"), type='success', ) def undo_cancel(self): @@ -425,7 +447,7 @@ def delete(self): pass @with_roles(call={'project_editor'}) - def move_to(self, project): + def move_to(self, project: Project) -> None: """Move to a new project and reset :attr:`url_id`.""" self.project = project self.url_id = None # pylint: disable=attribute-defined-outside-init @@ -435,7 +457,7 @@ def update_description(self) -> None: if not self.custom_description: self.description = preview(self.body_html) - def getnext(self): + def getnext(self) -> Proposal | None: return ( Proposal.query.filter( Proposal.project == self.project, @@ -445,7 +467,7 @@ def getnext(self): .first() ) - def getprev(self): + def getprev(self) -> Proposal | None: return ( Proposal.query.filter( Proposal.project == self.project, @@ -456,7 +478,7 @@ def getprev(self): ) 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 self.state.DRAFT: @@ -472,11 +494,13 @@ def roles_for( return roles @classmethod - def all_public(cls): + def all_public(cls) -> Query[Proposal]: return cls.query.join(Project).filter(Project.state.PUBLISHED, cls.state.PUBLIC) @classmethod - def get(cls, uuid_b58): # pylint: disable=arguments-differ + def get( # type: ignore[override] # pylint: disable=arguments-differ + cls, uuid_b58: str + ) -> Proposal | None: """Get a proposal by its public Base58 id.""" return cls.query.filter_by(uuid_b58=uuid_b58).one_or_none() @@ -484,22 +508,23 @@ def get(cls, uuid_b58): # pylint: disable=arguments-differ add_search_trigger(Proposal, 'search_vector') -class ProposalSuuidRedirect(BaseMixin, db.Model): # type: ignore[name-defined] +class ProposalSuuidRedirect(BaseMixin, Model): """Holds Proposal SUUIDs from before when they were deprecated.""" __tablename__ = 'proposal_suuid_redirect' - __allow_unmapped__ = True - suuid = sa.Column(sa.Unicode(22), nullable=False, index=True) - proposal_id = sa.Column( + suuid = sa.orm.mapped_column(sa.Unicode(22), nullable=False, index=True) + proposal_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False ) - proposal: Mapped[Proposal] = sa.orm.relationship(Proposal) + proposal: Mapped[Proposal] = relationship(Proposal) @reopen(Commentset) class __Commentset: - proposal = sa.orm.relationship(Proposal, uselist=False, back_populates='commentset') + proposal: Mapped[Proposal] = relationship( + Proposal, uselist=False, back_populates='commentset' + ) @reopen(Project) @@ -549,11 +574,11 @@ def proposals_by_confirmation(self): # Whether the project has any featured proposals. Returns `None` instead of # a boolean if the project does not have any proposal. - _has_featured_proposals = sa.orm.column_property( + _has_featured_proposals: Mapped[bool | None] = sa.orm.column_property( sa.exists() .where(Proposal.project_id == Project.id) .where(Proposal.featured.is_(True)) - .correlate_except(Proposal), # type: ignore[arg-type] + .correlate_except(Proposal), deferred=True, ) diff --git a/funnel/models/proposal_membership.py b/funnel/models/proposal_membership.py index 6a1be01c5..1813a6138 100644 --- a/funnel/models/proposal_membership.py +++ b/funnel/models/proposal_membership.py @@ -2,14 +2,12 @@ from __future__ import annotations -from typing import Set -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - from werkzeug.utils import cached_property from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles -from . import Mapped, db, sa +from . import DynamicMapped, Mapped, Model, backref, relationship, sa +from .account import Account from .helpers import reopen from .membership_mixin import ( FrozenAttributionMixin, @@ -18,28 +16,23 @@ ) from .project import Project from .proposal import Proposal -from .user import User __all__ = ['ProposalMembership'] class ProposalMembership( # type: ignore[misc] - FrozenAttributionMixin, - ReorderMembershipMixin, - ImmutableUserMembershipMixin, - db.Model, # type: ignore[name-defined] + FrozenAttributionMixin, ReorderMembershipMixin, ImmutableUserMembershipMixin, Model ): """Users can be presenters or reviewers on proposals.""" __tablename__ = 'proposal_membership' - __allow_unmapped__ = True # List of data columns in this model __data_columns__ = ('seq', 'is_uncredited', 'label', 'title') __roles__ = { 'all': { - 'read': {'is_uncredited', 'label', 'seq', 'title', 'urls', 'user'}, + 'read': {'is_uncredited', 'label', 'seq', 'title', 'urls', 'member'}, 'call': {'url_for'}, }, 'editor': { @@ -55,7 +48,7 @@ class ProposalMembership( # type: ignore[misc] 'seq', 'title', 'urls', - 'user', + 'member', 'uuid_b58', }, 'without_parent': { @@ -65,7 +58,7 @@ class ProposalMembership( # type: ignore[misc] 'seq', 'title', 'urls', - 'user', + 'member', 'uuid_b58', }, 'related': { @@ -79,28 +72,28 @@ class ProposalMembership( # type: ignore[misc] }, } - revoke_on_subject_delete = False + revoke_on_member_delete = False proposal_id: Mapped[int] = with_roles( - sa.Column( + sa.orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False, ), - read={'subject', 'editor'}, + read={'member', 'editor'}, ) proposal: Mapped[Proposal] = with_roles( - sa.orm.relationship( + relationship( Proposal, - backref=sa.orm.backref( + backref=backref( 'all_memberships', lazy='dynamic', cascade='all', passive_deletes=True, ), ), - read={'subject', 'editor'}, + read={'member', 'editor'}, grants_via={None: {'editor'}}, ) parent_id: Mapped[int] = sa.orm.synonym('proposal_id') @@ -110,11 +103,11 @@ class ProposalMembership( # type: ignore[misc] #: Uncredited members are not listed in the main display, but can edit and may be #: listed in a details section. Uncredited memberships are for support roles such #: as copy editors. - is_uncredited = sa.Column(sa.Boolean, nullable=False, default=False) + is_uncredited = sa.orm.mapped_column(sa.Boolean, nullable=False, default=False) #: Optional label, indicating the member's role on the proposal label = immutable( - sa.Column( + sa.orm.mapped_column( sa.Unicode, sa.CheckConstraint("label <> ''", name='proposal_membership_label_check'), nullable=True, @@ -122,7 +115,7 @@ class ProposalMembership( # type: ignore[misc] ) @cached_property - def offered_roles(self) -> Set[str]: + def offered_roles(self) -> set[str]: """Roles offered by this membership record.""" # This method is not used. See the `Proposal.memberships` relationship below. return {'submitter', 'editor'} @@ -131,61 +124,61 @@ def offered_roles(self) -> Set[str]: # Project relationships @reopen(Proposal) class __Proposal: - user: User + created_by: Account # This relationship does not use `lazy='dynamic'` because it is expected to contain # <2 records on average, and won't exceed 50 in the most extreme cases memberships = with_roles( - sa.orm.relationship( + relationship( ProposalMembership, primaryjoin=sa.and_( ProposalMembership.proposal_id == Proposal.id, - ProposalMembership.is_active, # type: ignore[arg-type] + ProposalMembership.is_active, ), order_by=ProposalMembership.seq, viewonly=True, ), read={'all'}, # These grants are authoritative and used instead of `offered_roles` above - grants_via={'user': {'submitter', 'editor'}}, + grants_via={'member': {'submitter', 'editor'}}, ) @property - def first_user(self) -> User: + def first_user(self) -> Account: """Return the first credited member on the proposal, or creator if none.""" for membership in self.memberships: if not membership.is_uncredited: - return membership.user - return self.user + return membership.member + return self.created_by -@reopen(User) -class __User: +@reopen(Account) +class __Account: # pylint: disable=invalid-unary-operand-type - all_proposal_memberships = sa.orm.relationship( + all_proposal_memberships: DynamicMapped[ProposalMembership] = relationship( ProposalMembership, lazy='dynamic', - foreign_keys=[ProposalMembership.user_id], + foreign_keys=[ProposalMembership.member_id], viewonly=True, ) - noninvite_proposal_memberships = sa.orm.relationship( + noninvite_proposal_memberships: DynamicMapped[ProposalMembership] = relationship( ProposalMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalMembership.user_id == User.id, - ~ProposalMembership.is_invite, # type: ignore[operator] + ProposalMembership.member_id == Account.id, + ~ProposalMembership.is_invite, ), viewonly=True, ) - proposal_memberships = sa.orm.relationship( + proposal_memberships: DynamicMapped[ProposalMembership] = relationship( ProposalMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalMembership.user_id == User.id, - ProposalMembership.is_active, # type: ignore[arg-type] + ProposalMembership.member_id == Account.id, + ProposalMembership.is_active, ), viewonly=True, ) @@ -209,5 +202,5 @@ def public_proposal_memberships(self): ) -User.__active_membership_attrs__.add('proposal_memberships') -User.__noninvite_membership_attrs__.add('noninvite_proposal_memberships') +Account.__active_membership_attrs__.add('proposal_memberships') +Account.__noninvite_membership_attrs__.add('noninvite_proposal_memberships') diff --git a/funnel/models/reorder_mixin.py b/funnel/models/reorder_mixin.py index 48f117ad1..b8367035e 100644 --- a/funnel/models/reorder_mixin.py +++ b/funnel/models/reorder_mixin.py @@ -3,12 +3,10 @@ from __future__ import annotations from datetime import datetime -from typing import TypeVar, Union +from typing import TYPE_CHECKING, ClassVar, TypeVar from uuid import UUID -from coaster.sqlalchemy import Query - -from . import db, declarative_mixin, sa +from . import Mapped, QueryProperty, db, declarative_mixin, sa __all__ = ['ReorderMixin'] @@ -22,18 +20,20 @@ class ReorderMixin: """Adds support for re-ordering sequences within a parent container.""" - #: Subclasses must have a created_at column - created_at: datetime - #: Subclass must have a primary key that is int or uuid - id: int # noqa: A003 - #: Subclass must declare a parent_id synonym to the parent model fkey column - parent_id: Union[int, UUID] - #: Subclass must declare a seq column or synonym, holding a sequence id. It need not - #: be unique, but reordering is meaningless when both items have the same number - seq: int - - #: Subclass must offer a SQLAlchemy query (this is standard from base classes) - query: Query + if TYPE_CHECKING: + #: Subclasses must have a created_at column + created_at: Mapped[datetime] + #: Subclass must have a primary key that is int or uuid + id: Mapped[int] # noqa: A001 + #: Subclass must declare a parent_id synonym to the parent model fkey column + parent_id: Mapped[int | UUID] + #: Subclass must declare a seq column or synonym, holding a sequence id. It + #: need not be unique, but reordering is meaningless when both items have the + #: same number + seq: Mapped[int] + + #: Subclass must offer a SQLAlchemy query (this is standard from base classes) + query: ClassVar[QueryProperty] @property def parent_scoped_reorder_query_filter(self: Reorderable): diff --git a/funnel/models/rsvp.py b/funnel/models/rsvp.py index bd92cb435..ac6390686 100644 --- a/funnel/models/rsvp.py +++ b/funnel/models/rsvp.py @@ -2,23 +2,31 @@ from __future__ import annotations -from typing import Dict, Optional, Tuple, Union, cast, overload +from typing import Literal, cast, overload from flask import current_app from werkzeug.utils import cached_property -from typing_extensions import Literal - from baseframe import __ from coaster.sqlalchemy import StateManager, with_roles from coaster.utils import LabeledEnum -from ..typing import OptionalMigratedTables -from . import NoIdMixin, UuidMixin, db, sa +from . import ( + Mapped, + Model, + NoIdMixin, + Query, + UuidMixin, + backref, + db, + relationship, + sa, + types, +) +from .account import Account, AccountEmail, AccountEmailClaim, AccountPhone from .helpers import reopen from .project import Project from .project_membership import project_child_role_map -from .user import User, UserEmail, UserEmailClaim, UserPhone __all__ = ['Rsvp', 'RSVP_STATUS'] @@ -30,35 +38,36 @@ class RSVP_STATUS(LabeledEnum): # noqa: N801 NO = ('N', 'no', __("Not going")) MAYBE = ('M', 'maybe', __("Maybe")) AWAITING = ('A', 'awaiting', __("Awaiting")) - __order__ = (YES, NO, MAYBE, AWAITING) - # USER_CHOICES = {YES, NO, MAYBE} -class Rsvp(UuidMixin, NoIdMixin, db.Model): # type: ignore[name-defined] +class Rsvp(UuidMixin, NoIdMixin, Model): __tablename__ = 'rsvp' - __allow_unmapped__ = True - project_id = sa.Column( + project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False, primary_key=True ) - project = with_roles( - sa.orm.relationship( - Project, backref=sa.orm.backref('rsvps', cascade='all', lazy='dynamic') - ), + project: Mapped[Project] = with_roles( + relationship(Project, backref=backref('rsvps', cascade='all', lazy='dynamic')), read={'owner', 'project_promoter'}, grants_via={None: project_child_role_map}, + datasets={'primary'}, ) - user_id = sa.Column( - sa.Integer, sa.ForeignKey('user.id'), nullable=False, primary_key=True + participant_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False, primary_key=True ) - user = with_roles( - sa.orm.relationship( - User, backref=sa.orm.backref('rsvps', cascade='all', lazy='dynamic') - ), + participant: Mapped[Account] = with_roles( + relationship(Account, backref=backref('rsvps', cascade='all', lazy='dynamic')), read={'owner', 'project_promoter'}, grants={'owner'}, + datasets={'primary', 'without_parent'}, + ) + form: Mapped[types.jsonb | None] = with_roles( + sa.orm.mapped_column(), + rw={'owner'}, + read={'project_promoter'}, + datasets={'primary', 'without_parent', 'related'}, ) - _state = sa.Column( + _state = sa.orm.mapped_column( 'state', sa.CHAR(1), StateManager.check_constraint('state', RSVP_STATUS), @@ -75,18 +84,16 @@ class Rsvp(UuidMixin, NoIdMixin, db.Model): # type: ignore[name-defined] 'project_promoter': {'read': {'created_at', 'updated_at'}}, } - __datasets__ = { - 'primary': {'project', 'user', 'response'}, - 'without_parent': {'user', 'response'}, - 'related': {'response'}, - } - @property def response(self): """Return RSVP response as a raw value.""" return self._state - with_roles(response, read={'owner', 'project_promoter'}) + with_roles( + response, + read={'owner', 'project_promoter'}, + datasets={'primary', 'without_parent', 'related'}, + ) @with_roles(call={'owner'}) @state.transition( @@ -122,74 +129,72 @@ def rsvp_maybe(self): pass @with_roles(call={'owner', 'project_promoter'}) - def user_email(self) -> Optional[UserEmail]: - """User's preferred email address for this registration.""" - return self.user.transport_for_email(self.project.profile) + def participant_email(self) -> AccountEmail | None: + """Participant's preferred email address for this registration.""" + return self.participant.transport_for_email(self.project.account) @with_roles(call={'owner', 'project_promoter'}) - def user_phone(self) -> Optional[UserEmail]: - """User's preferred phone number for this registration.""" - return self.user.transport_for_sms(self.project.profile) + def participant_phone(self) -> AccountEmail | None: + """Participant's preferred phone number for this registration.""" + return self.participant.transport_for_sms(self.project.account) @with_roles(call={'owner', 'project_promoter'}) def best_contact( self, - ) -> Tuple[Union[UserEmail, UserEmailClaim, UserPhone, None], str]: - email = self.user_email() + ) -> tuple[AccountEmail | AccountEmailClaim | AccountPhone | None, str]: + email = self.participant_email() if email: return email, 'e' - phone = self.user_phone() + phone = self.participant_phone() if phone: return phone, 'p' - if self.user.emailclaims: - return self.user.emailclaims[0], 'ec' + if self.participant.emailclaims: + return self.participant.emailclaims[0], 'ec' return None, '' @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.""" - project_ids = {rsvp.project_id for rsvp in new_user.rsvps} - for rsvp in old_user.rsvps: + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" + project_ids = {rsvp.project_id for rsvp in new_account.rsvps} + for rsvp in old_account.rsvps: if rsvp.project_id not in project_ids: - rsvp.user = new_user + rsvp.participant = new_account else: current_app.logger.warning( "Discarding conflicting RSVP (%s) from %r on %r", rsvp._state, # pylint: disable=protected-access - old_user, + old_account, rsvp.project, ) db.session.delete(rsvp) @overload @classmethod - def get_for(cls, project: Project, user: User, create: Literal[True]) -> Rsvp: + def get_for(cls, project: Project, user: Account, create: Literal[True]) -> Rsvp: ... @overload @classmethod def get_for( - cls, project: Project, user: User, create: Literal[False] - ) -> Optional[Rsvp]: + cls, project: Project, account: Account, create: Literal[False] + ) -> Rsvp | None: ... @overload @classmethod def get_for( - cls, project: Project, user: Optional[User], create=False - ) -> Optional[Rsvp]: + cls, project: Project, account: Account | None, create=False + ) -> Rsvp | None: ... @classmethod def get_for( - cls, project: Project, user: Optional[User], create=False - ) -> Optional[Rsvp]: - if user is not None: - result = cls.query.get((project.id, user.id)) + cls, project: Project, account: Account | None, create=False + ) -> Rsvp | None: + if account is not None: + result = cls.query.get((project.id, account.id)) if not result and create: - result = cls(project=project, user=user) + result = cls(project=project, participant=account) db.session.add(result) return result return None @@ -199,39 +204,42 @@ def get_for( class __Project: @property def active_rsvps(self): - return self.rsvps.join(User).filter(Rsvp.state.YES, User.state.ACTIVE) + return self.rsvps.join(Account).filter(Rsvp.state.YES, Account.state.ACTIVE) - with_roles(active_rsvps, grants_via={Rsvp.user: {'participant'}}) + with_roles( + active_rsvps, + grants_via={Rsvp.participant: {'participant', 'project_participant'}}, + ) @overload - def rsvp_for(self, user: User, create: Literal[True]) -> Rsvp: + def rsvp_for(self, account: Account, create: Literal[True]) -> Rsvp: ... @overload - def rsvp_for(self, user: Optional[User], create: Literal[False]) -> Optional[Rsvp]: + def rsvp_for(self, account: Account | None, create: Literal[False]) -> Rsvp | None: ... - def rsvp_for(self, user: Optional[User], create=False) -> Optional[Rsvp]: - return Rsvp.get_for(cast(Project, self), user, create) + def rsvp_for(self, account: Account | None, create=False) -> Rsvp | None: + return Rsvp.get_for(cast(Project, self), account, create) def rsvps_with(self, status: str): return ( cast(Project, self) - .rsvps.join(User) + .rsvps.join(Account) .filter( - User.state.ACTIVE, + Account.state.ACTIVE, Rsvp._state == status, # pylint: disable=protected-access ) ) - def rsvp_counts(self) -> Dict[str, int]: + def rsvp_counts(self) -> dict[str, int]: return dict( db.session.query( Rsvp._state, # pylint: disable=protected-access sa.func.count(Rsvp._state), # pylint: disable=protected-access ) - .join(User) - .filter(User.state.ACTIVE, Rsvp.project == self) + .join(Account) + .filter(Account.state.ACTIVE, Rsvp.project == self) .group_by(Rsvp._state) # pylint: disable=protected-access .all() ) @@ -240,7 +248,22 @@ def rsvp_counts(self) -> Dict[str, int]: def rsvp_count_going(self) -> int: return ( cast(Project, self) - .rsvps.join(User) - .filter(User.state.ACTIVE, Rsvp.state.YES) + .rsvps.join(Account) + .filter(Account.state.ACTIVE, Rsvp.state.YES) .count() ) + + +@reopen(Account) +class __Account: + @property + def rsvp_followers(self) -> Query[Account]: + """All users with an active RSVP in a project.""" + return ( + Account.query.filter(Account.state.ACTIVE) + .join(Rsvp, Rsvp.participant_id == Account.id) + .join(Project, Rsvp.project_id == Project.id) + .filter(Rsvp.state.YES, Project.state.PUBLISHED, Project.account == self) + ) + + with_roles(rsvp_followers, grants={'follower'}) diff --git a/funnel/models/saved.py b/funnel/models/saved.py index 5cd5b3a82..4310c14ad 100644 --- a/funnel/models/saved.py +++ b/funnel/models/saved.py @@ -2,126 +2,123 @@ from __future__ import annotations -from typing import Iterable, Optional +from collections.abc import Sequence from coaster.sqlalchemy import LazyRoleSet, with_roles -from ..typing import OptionalMigratedTables -from . import Mapped, NoIdMixin, db, sa +from . import Mapped, Model, NoIdMixin, backref, db, relationship, sa +from .account import Account from .helpers import reopen from .project import Project from .session import Session -from .user import User -class SavedProject(NoIdMixin, db.Model): # type: ignore[name-defined] - #: User who saved this project - user_id = sa.Column( - sa.Integer, - sa.ForeignKey('user.id', ondelete='CASCADE'), +class SavedProject(NoIdMixin, Model): + __tablename__ = 'saved_project' + + #: User account that saved this project + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id', ondelete='CASCADE'), nullable=False, primary_key=True, ) - user: Mapped[User] = sa.orm.relationship( - User, - backref=sa.orm.backref('saved_projects', lazy='dynamic', passive_deletes=True), + account: Mapped[Account] = relationship( + Account, + backref=backref('saved_projects', lazy='dynamic', passive_deletes=True), ) #: Project that was saved - project_id = sa.Column( + project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False, primary_key=True, index=True, ) - project: Mapped[Project] = sa.orm.relationship( + project: Mapped[Project] = relationship( Project, - backref=sa.orm.backref('saved_by', lazy='dynamic', passive_deletes=True), + backref=backref('saved_by', lazy='dynamic', passive_deletes=True), ) #: Timestamp when the save happened - saved_at = sa.Column( + saved_at = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) #: User's plaintext note to self on why they saved this (optional) - description = sa.Column(sa.UnicodeText, nullable=True) + description = sa.orm.mapped_column(sa.UnicodeText, nullable=True) 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 and actor == self.user: + if actor is not None and actor == self.account: roles.add('owner') 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.""" - project_ids = {sp.project_id for sp in new_user.saved_projects} - for sp in old_user.saved_projects: + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" + project_ids = {sp.project_id for sp in new_account.saved_projects} + for sp in old_account.saved_projects: if sp.project_id not in project_ids: - sp.user = new_user + sp.account = new_account else: db.session.delete(sp) -class SavedSession(NoIdMixin, db.Model): # type: ignore[name-defined] - #: User who saved this session - user_id = sa.Column( - sa.Integer, - sa.ForeignKey('user.id', ondelete='CASCADE'), +class SavedSession(NoIdMixin, Model): + __tablename__ = 'saved_session' + + #: User account that saved this session + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id', ondelete='CASCADE'), nullable=False, primary_key=True, ) - user: Mapped[User] = sa.orm.relationship( - User, - backref=sa.orm.backref('saved_sessions', lazy='dynamic', passive_deletes=True), + account: Mapped[Account] = relationship( + Account, + backref=backref('saved_sessions', lazy='dynamic', passive_deletes=True), ) #: Session that was saved - session_id = sa.Column( + session_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('session.id', ondelete='CASCADE'), nullable=False, primary_key=True, index=True, ) - session: Mapped[Session] = sa.orm.relationship( + session: Mapped[Session] = relationship( Session, - backref=sa.orm.backref('saved_by', lazy='dynamic', passive_deletes=True), + backref=backref('saved_by', lazy='dynamic', passive_deletes=True), ) #: Timestamp when the save happened - saved_at = sa.Column( + saved_at = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) #: User's plaintext note to self on why they saved this (optional) - description = sa.Column(sa.UnicodeText, nullable=True) + description = sa.orm.mapped_column(sa.UnicodeText, nullable=True) 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 and actor == self.user: + if actor is not None and actor == self.account: roles.add('owner') 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.""" - project_ids = {ss.project_id for ss in new_user.saved_sessions} - for ss in old_user.saved_sessions: + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" + project_ids = {ss.project_id for ss in new_account.saved_sessions} + for ss in old_account.saved_sessions: if ss.project_id not in project_ids: - ss.user = new_user + ss.account = new_account else: # TODO: `if ss.description`, don't discard, but add it to existing's # description db.session.delete(ss) -@reopen(User) -class __User: +@reopen(Account) +class __Account: def saved_sessions_in(self, project): return self.saved_sessions.join(Session).filter(Session.project == project) @@ -129,7 +126,7 @@ def saved_sessions_in(self, project): @reopen(Project) class __Project: @with_roles(call={'all'}) - def is_saved_by(self, user): + def is_saved_by(self, account: Account) -> bool: return ( - user is not None and self.saved_by.filter_by(user=user).first() is not None + account is not None and self.saved_by.filter_by(account=account).notempty() ) diff --git a/funnel/models/session.py b/funnel/models/session.py index 3a36bfadd..f6b34289c 100644 --- a/funnel/models/session.py +++ b/funnel/models/session.py @@ -4,14 +4,11 @@ from collections import OrderedDict, defaultdict from datetime import datetime, timedelta -from typing import Any, Dict, Optional, Type -from uuid import UUID # noqa: F401 # pylint: disable=unused-import +from typing import Any -from flask_babel import get_locale -from werkzeug.utils import cached_property - -from babel.dates import format_date +from flask_babel import format_date, get_locale from isoweek import Week +from werkzeug.utils import cached_property from baseframe import localize_timezone from coaster.sqlalchemy import with_roles @@ -19,96 +16,112 @@ from . import ( BaseScopedIdNameMixin, + DynamicMapped, Mapped, - MarkdownCompositeDocument, + Model, + Query, TSVectorType, UuidMixin, + backref, db, hybrid_property, + relationship, sa, ) -from .helpers import ImgeeType, add_search_trigger, reopen, visual_field_delimiter +from .account import Account +from .helpers import ( + ImgeeType, + MarkdownCompositeDocument, + add_search_trigger, + reopen, + visual_field_delimiter, +) from .project import Project from .project_membership import project_child_role_map from .proposal import Proposal -from .user import User from .venue import VenueRoom from .video_mixin import VideoMixin __all__ = ['Session'] -class Session( - UuidMixin, - BaseScopedIdNameMixin, - VideoMixin, - db.Model, # type: ignore[name-defined] -): +class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): __tablename__ = 'session' - __allow_unmapped__ = True - project_id = sa.Column(sa.Integer, sa.ForeignKey('project.id'), nullable=False) + project_id: Mapped[int] = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('project.id'), nullable=False + ) project: Mapped[Project] = with_roles( - sa.orm.relationship( - Project, backref=sa.orm.backref('sessions', cascade='all', lazy='dynamic') + relationship( + Project, backref=backref('sessions', cascade='all', lazy='dynamic') ), grants_via={None: project_child_role_map}, ) parent: Mapped[Project] = sa.orm.synonym('project') - description = MarkdownCompositeDocument.create( + description, description_text, description_html = MarkdownCompositeDocument.create( 'description', default='', nullable=False ) - proposal_id = sa.Column( + proposal_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id'), nullable=True, unique=True ) - proposal: Mapped[Optional[Proposal]] = sa.orm.relationship( - Proposal, backref=sa.orm.backref('session', uselist=False, cascade='all') + proposal: Mapped[Proposal | None] = relationship( + Proposal, backref=backref('session', uselist=False, cascade='all') + ) + speaker = sa.orm.mapped_column(sa.Unicode(200), default=None, nullable=True) + start_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True, index=True + ) + end_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True, index=True + ) + venue_room_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('venue_room.id'), nullable=True + ) + venue_room: Mapped[VenueRoom | None] = relationship(VenueRoom, backref='sessions') + is_break = sa.orm.mapped_column(sa.Boolean, default=False, nullable=False) + featured = sa.orm.mapped_column(sa.Boolean, default=False, nullable=False) + is_restricted_video = sa.orm.mapped_column( + sa.Boolean, default=False, nullable=False ) - speaker = sa.Column(sa.Unicode(200), default=None, nullable=True) - start_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True, index=True) - end_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True, index=True) - venue_room_id = sa.Column(sa.Integer, sa.ForeignKey('venue_room.id'), nullable=True) - venue_room: Mapped[Optional[VenueRoom]] = sa.orm.relationship( - VenueRoom, backref=sa.orm.backref('sessions') + banner_image_url: Mapped[str | None] = sa.orm.mapped_column( + ImgeeType, nullable=True ) - is_break = sa.Column(sa.Boolean, default=False, nullable=False) - featured = sa.Column(sa.Boolean, default=False, nullable=False) - banner_image_url: Mapped[Optional[str]] = sa.Column(ImgeeType, nullable=True) #: Version number maintained by SQLAlchemy, used for vCal files, starting at 1 - revisionid = with_roles(sa.Column(sa.Integer, nullable=False), read={'all'}) + revisionid = with_roles( + sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + ) - search_vector: Mapped[TSVectorType] = sa.orm.deferred( - sa.Column( - TSVectorType( - 'title', - 'description_text', - 'speaker', - weights={ - 'title': 'A', - 'description_text': 'B', - 'speaker': 'A', - }, - regconfig='english', - hltext=lambda: sa.func.concat_ws( - visual_field_delimiter, - Session.title, - Session.speaker, - Session.description_html, - ), + search_vector: Mapped[TSVectorType] = sa.orm.mapped_column( + TSVectorType( + 'title', + 'description_text', + 'speaker', + weights={ + 'title': 'A', + 'description_text': 'B', + 'speaker': 'A', + }, + regconfig='english', + hltext=lambda: sa.func.concat_ws( + visual_field_delimiter, + Session.title, + Session.speaker, + Session.description_html, ), - nullable=False, - ) + ), + nullable=False, + deferred=True, ) __table_args__ = ( sa.UniqueConstraint('project_id', 'url_id'), sa.CheckConstraint( - sa.or_( # type: ignore[arg-type] + sa.or_( sa.and_(start_at.is_(None), end_at.is_(None)), sa.and_( - start_at.isnot(None), - end_at.isnot(None), + start_at.is_not(None), + end_at.is_not(None), end_at > start_at, end_at <= start_at + sa.text("INTERVAL '1 day'"), ), @@ -135,6 +148,7 @@ class Session( 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -157,6 +171,7 @@ class Session( 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -172,6 +187,7 @@ class Session( 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -187,6 +203,7 @@ class Session( 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -194,22 +211,24 @@ class Session( } @hybrid_property - def user(self) -> Optional[User]: + def user(self) -> Account | None: if self.proposal is not None: return self.proposal.first_user - return None # type: ignore[unreachable] + return None @hybrid_property - def scheduled(self): + def scheduled(self) -> bool: # A session is scheduled only when both start and end fields have a value return self.start_at is not None and self.end_at is not None - @scheduled.expression - def scheduled(cls): # noqa: N805 # pylint: disable=no-self-argument - return (cls.start_at.isnot(None)) & (cls.end_at.isnot(None)) + @scheduled.inplace.expression + @classmethod + def _scheduled_expression(cls) -> sa.ColumnElement[bool]: + """Return SQL Expression.""" + return (cls.start_at.is_not(None)) & (cls.end_at.is_not(None)) @cached_property - def start_at_localized(self): + def start_at_localized(self) -> datetime | None: return ( localize_timezone(self.start_at, tz=self.project.timezone) if self.start_at @@ -217,7 +236,7 @@ def start_at_localized(self): ) @cached_property - def end_at_localized(self): + def end_at_localized(self) -> datetime | None: return ( localize_timezone(self.end_at, tz=self.project.timezone) if self.end_at @@ -241,7 +260,7 @@ def location(self) -> str: with_roles(location, read={'all'}) @classmethod - def for_proposal(cls, proposal, create=False): + def for_proposal(cls, proposal: Proposal, create: bool = False) -> Session | None: session_obj = cls.query.filter_by(proposal=proposal).first() if session_obj is None and create: session_obj = cls( @@ -253,14 +272,14 @@ def for_proposal(cls, proposal, create=False): db.session.add(session_obj) return session_obj - def make_unscheduled(self): + def make_unscheduled(self) -> None: # Session is not deleted, but we remove start and end time, # so it becomes an unscheduled session. self.start_at = None self.end_at = None @classmethod - def all_public(cls): + def all_public(cls) -> Query[Session]: return cls.query.join(Project).filter(Project.state.PUBLISHED, cls.scheduled) @@ -269,11 +288,11 @@ def all_public(cls): @reopen(VenueRoom) class __VenueRoom: - scheduled_sessions = sa.orm.relationship( + scheduled_sessions: Mapped[list[Session]] = relationship( Session, primaryjoin=sa.and_( Session.venue_room_id == VenueRoom.id, - Session.scheduled, # type: ignore[arg-type] + Session.scheduled, ), viewonly=True, ) @@ -283,24 +302,24 @@ class __VenueRoom: class __Project: # Project schedule column expressions. Guide: # https://docs.sqlalchemy.org/en/13/orm/mapped_sql_expr.html#using-column-property - schedule_start_at = with_roles( + schedule_start_at: Mapped[datetime | None] = with_roles( sa.orm.column_property( sa.select(sa.func.min(Session.start_at)) - .where(Session.start_at.isnot(None)) + .where(Session.start_at.is_not(None)) .where(Session.project_id == Project.id) - .correlate_except(Session) # type: ignore[arg-type] + .correlate_except(Session) .scalar_subquery() ), read={'all'}, datasets={'primary', 'without_parent'}, ) - next_session_at = with_roles( + next_session_at: Mapped[datetime | None] = with_roles( sa.orm.column_property( sa.select(sa.func.min(sa.column('start_at'))) .select_from( sa.select(sa.func.min(Session.start_at).label('start_at')) - .where(Session.start_at.isnot(None)) + .where(Session.start_at.is_not(None)) .where(Session.start_at >= sa.func.utcnow()) .where(Session.project_id == Project.id) .correlate_except(Session) # type: ignore[arg-type] @@ -308,24 +327,25 @@ class __Project: sa.select( Project.start_at.label('start_at') # type: ignore[has-type] ) - .where(Project.start_at.isnot(None)) # type: ignore[has-type] + .where(Project.start_at.is_not(None)) # type: ignore[has-type] .where( Project.start_at >= sa.func.utcnow() # type: ignore[has-type] ) - .correlate(Project) # type: ignore[arg-type] + .correlate(Project) ) + .subquery() ) .scalar_subquery() ), read={'all'}, ) - schedule_end_at = with_roles( + schedule_end_at: Mapped[datetime | None] = with_roles( sa.orm.column_property( sa.select(sa.func.max(Session.end_at)) - .where(Session.end_at.isnot(None)) + .where(Session.end_at.is_not(None)) .where(Session.project_id == Project.id) - .correlate_except(Session) # type: ignore[arg-type] + .correlate_except(Session) .scalar_subquery() ), read={'all'}, @@ -334,7 +354,7 @@ class __Project: @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property - def schedule_start_at_localized(self): + def schedule_start_at_localized(self) -> datetime | None: return ( localize_timezone(self.schedule_start_at, tz=self.timezone) if self.schedule_start_at @@ -343,7 +363,7 @@ def schedule_start_at_localized(self): @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property - def schedule_end_at_localized(self): + def schedule_end_at_localized(self) -> datetime | None: return ( localize_timezone(self.schedule_end_at, tz=self.timezone) if self.schedule_end_at @@ -352,11 +372,11 @@ def schedule_end_at_localized(self): @with_roles(read={'all'}) @cached_property - def session_count(self): - return self.sessions.filter(Session.start_at.isnot(None)).count() + def session_count(self) -> int: + return self.sessions.filter(Session.start_at.is_not(None)).count() - featured_sessions = with_roles( - sa.orm.relationship( + featured_sessions: Mapped[list[Session]] = with_roles( + relationship( Session, order_by=Session.start_at.asc(), primaryjoin=sa.and_( @@ -366,39 +386,39 @@ def session_count(self): ), read={'all'}, ) - scheduled_sessions = with_roles( - sa.orm.relationship( + scheduled_sessions: Mapped[list[Session]] = with_roles( + relationship( Session, order_by=Session.start_at.asc(), primaryjoin=sa.and_( Session.project_id == Project.id, - Session.scheduled, # type: ignore[arg-type] + Session.scheduled, ), viewonly=True, ), read={'all'}, ) - unscheduled_sessions = with_roles( - sa.orm.relationship( + unscheduled_sessions: Mapped[list[Session]] = with_roles( + relationship( Session, order_by=Session.start_at.asc(), primaryjoin=sa.and_( Session.project_id == Project.id, - Session.scheduled.isnot(True), # type: ignore[attr-defined] + Session.scheduled.is_not(True), ), viewonly=True, ), read={'all'}, ) - sessions_with_video = with_roles( - sa.orm.relationship( + sessions_with_video: DynamicMapped[Session] = with_roles( + relationship( Session, lazy='dynamic', primaryjoin=sa.and_( Project.id == Session.project_id, - Session.video_id.isnot(None), - Session.video_source.isnot(None), + Session.video_id.is_not(None), + Session.video_source.is_not(None), ), viewonly=True, ), @@ -407,14 +427,14 @@ def session_count(self): @with_roles(read={'all'}) @cached_property - def has_sessions_with_video(self): + def has_sessions_with_video(self) -> bool: return self.query.session.query(self.sessions_with_video.exists()).scalar() - def next_session_from(self, timestamp): + def next_session_from(self, timestamp: datetime) -> Session | None: """Find the next session in this project from given timestamp.""" return ( self.sessions.filter( - Session.start_at.isnot(None), Session.start_at >= timestamp + Session.start_at.is_not(None), Session.start_at >= timestamp ) .order_by(Session.start_at.asc()) .first() @@ -422,8 +442,8 @@ def next_session_from(self, timestamp): @with_roles(call={'all'}) def next_starting_at( # type: ignore[misc] - self: Project, timestamp: Optional[datetime] = None - ) -> Optional[datetime]: + self: Project, timestamp: datetime | None = None + ) -> datetime | None: """ Return timestamp of next session from given timestamp. @@ -441,7 +461,7 @@ def next_starting_at( # type: ignore[misc] return ( db.session.query(sa.func.min(Session.start_at)) .filter( - Session.start_at.isnot(None), + Session.start_at.is_not(None), Session.start_at >= timestamp, Session.project == self, ) @@ -452,8 +472,8 @@ def next_starting_at( # type: ignore[misc] @classmethod def starting_at( # type: ignore[misc] - cls: Type[Project], timestamp: datetime, within: timedelta, gap: timedelta - ): + cls: type[Project], timestamp: datetime, within: timedelta, gap: timedelta + ) -> Query[Project]: """ Return projects that are about to start, for sending notifications. @@ -475,14 +495,14 @@ def starting_at( # type: ignore[misc] cls.query.filter( cls.id.in_( db.session.query(sa.func.distinct(Session.project_id)).filter( - Session.start_at.isnot(None), + Session.start_at.is_not(None), Session.start_at >= timestamp, Session.start_at < timestamp + within, Session.project_id.notin_( db.session.query( sa.func.distinct(Session.project_id) ).filter( - Session.start_at.isnot(None), + Session.start_at.is_not(None), sa.or_( sa.and_( Session.start_at >= timestamp - gap, @@ -503,14 +523,14 @@ def starting_at( # type: ignore[misc] ).union( cls.query.filter( cls.state.PUBLISHED, - cls.start_at.isnot(None), + cls.start_at.is_not(None), cls.start_at >= timestamp, cls.start_at < timestamp + within, ) ) @with_roles(call={'all'}) - def current_sessions(self: Project) -> Optional[dict]: # type: ignore[misc] + def current_sessions(self) -> dict | None: if self.start_at is None or (self.start_at > utcnow() + timedelta(minutes=30)): return None @@ -532,7 +552,8 @@ def current_sessions(self: Project) -> Optional[dict]: # type: ignore[misc] ], } - def calendar_weeks(self: Project, leading_weeks=True): # type: ignore[misc] + # TODO: Use TypedDict for return type + def calendar_weeks(self, leading_weeks: bool = True) -> dict[str, Any]: # session_dates is a list of tuples in this format - # (date, day_start_at, day_end_at, event_count) if self.schedule_start_at: @@ -548,8 +569,8 @@ def calendar_weeks(self: Project, leading_weeks=True): # type: ignore[misc] .select_from(Session) .filter( Session.project == self, - Session.start_at.isnot(None), - Session.end_at.isnot(None), + Session.start_at.is_not(None), + Session.end_at.is_not(None), ) .group_by('date') .order_by('date') @@ -626,7 +647,7 @@ def calendar_weeks(self: Project, leading_weeks=True): # type: ignore[misc] session_dates.insert(0, (now + timedelta(days=7), None, None, 0)) session_dates.insert(0, (now, None, None, 0)) - weeks: Dict[str, Dict[str, Any]] = defaultdict(dict) + weeks: dict[str, dict[str, Any]] = defaultdict(dict) today = now.date() for project_date, _day_start_at, _day_end_at, session_count in session_dates: weekobj = Week.withdate(project_date) @@ -643,9 +664,7 @@ def calendar_weeks(self: Project, leading_weeks=True): # type: ignore[misc] weeks[weekid]['upcoming'] = True weeks[weekid]['dates'][wdate] += session_count if 'month' not in weeks[weekid]: - weeks[weekid]['month'] = format_date( - wdate, 'MMM', locale=get_locale() - ) + weeks[weekid]['month'] = format_date(wdate, 'MMM') # Extract sorted weeks as a list weeks_list = [v for k, v in sorted(weeks.items())] @@ -656,7 +675,7 @@ def calendar_weeks(self: Project, leading_weeks=True): # type: ignore[misc] week['dates'] = [ { 'isoformat': date.isoformat(), - 'day': format_date(date, 'd', get_locale()), + 'day': format_date(date, 'd'), 'count': count, 'day_start_at': ( session_dates_dict[date]['day_start_at'] @@ -679,18 +698,15 @@ def calendar_weeks(self: Project, leading_weeks=True): # type: ignore[misc] 'locale': get_locale(), 'weeks': weeks_list, 'today': now.date().isoformat(), - 'days': [ - format_date(day, 'EEE', locale=get_locale()) - for day in Week.thisweek().days() - ], + 'days': [format_date(day, 'EEE') for day in Week.thisweek().days()], } @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property - def calendar_weeks_full(self): + def calendar_weeks_full(self) -> dict[str, Any]: # TODO: Use TypedDict return self.calendar_weeks(leading_weeks=True) @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property - def calendar_weeks_compact(self): + def calendar_weeks_compact(self) -> dict[str, Any]: # TODO: Use TypedDict return self.calendar_weeks(leading_weeks=False) diff --git a/funnel/models/shortlink.py b/funnel/models/shortlink.py index 246681dde..fb04c4795 100644 --- a/funnel/models/shortlink.py +++ b/funnel/models/shortlink.py @@ -2,23 +2,22 @@ from __future__ import annotations -from base64 import urlsafe_b64decode, urlsafe_b64encode -from os import urandom -from typing import Iterable, Optional, Union, overload import hashlib import re +from base64 import urlsafe_b64decode, urlsafe_b64encode +from collections.abc import Iterable +from os import urandom +from typing import Any, Literal, overload +from furl import furl from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.hybrid import Comparator -from furl import furl -from typing_extensions import Literal - from coaster.sqlalchemy import immutable, with_roles -from . import Mapped, NoIdMixin, UrlType, db, hybrid_property, sa +from . import Mapped, Model, NoIdMixin, UrlType, db, hybrid_property, relationship, sa +from .account import Account from .helpers import profanity -from .user import User __all__ = ['Shortlink'] @@ -49,7 +48,7 @@ # --- Helpers -------------------------------------------------------------------------- -def normalize_url(url: Union[str, furl], default_scheme: str = 'https') -> furl: +def normalize_url(url: str | furl, default_scheme: str = 'https') -> furl: """Normalize a URL with a default scheme and path.""" url = furl(url) if not url.scheme: @@ -78,7 +77,7 @@ def random_bigint(smaller: bool = False) -> int: return val -def name_to_bigint(value: Union[str, bytes]) -> int: +def name_to_bigint(value: str | bytes) -> int: """ Convert from a URL-safe Base64-encoded shortlink name to bigint. @@ -141,7 +140,7 @@ def bigint_to_name(value: int) -> str: ) -def url_blake2b160_hash(value: Union[str, furl]) -> bytes: +def url_blake2b160_hash(value: str | furl) -> bytes: """ Hash a URL, for duplicate URL lookup. @@ -166,23 +165,32 @@ class ShortLinkToBigIntComparator(Comparator): # pylint: disable=abstract-metho If the provided name is invalid, :func:`name_to_bigint` will raise exceptions. """ - def __eq__(self, other: Union[str, bytes]): # type: ignore[override] + def __eq__(self, other: Any) -> sa.ColumnElement[bool]: # type: ignore[override] """Return an expression for column == other.""" - return self.__clause_element__() == name_to_bigint(other) + if isinstance(other, (str, bytes)): + return self.__clause_element__() == name_to_bigint( + other + ) # type: ignore[return-value] + return sa.sql.expression.false() + + is_ = __eq__ # type: ignore[assignment] - def in_(self, other: Iterable[Union[str, bytes]]): # type: ignore[override] + def in_( # type: ignore[override] + self, other: Iterable[str | bytes] + ) -> sa.ColumnElement: """Return an expression for other IN column.""" - return self.__clause_element__().in_([name_to_bigint(v) for v in other]) + return self.__clause_element__().in_( # type: ignore[attr-defined] + [name_to_bigint(v) for v in other] + ) # --- Models --------------------------------------------------------------------------- -class Shortlink(NoIdMixin, db.Model): # type: ignore[name-defined] +class Shortlink(NoIdMixin, Model): """A short link to a full-size link, for use over SMS.""" __tablename__ = 'shortlink' - __allow_unmapped__ = True #: Non-persistent attribute for Shortlink.new to flag if this is a new shortlink. #: Any future instance cache system must NOT cache this value @@ -192,23 +200,25 @@ class Shortlink(NoIdMixin, db.Model): # type: ignore[name-defined] id = with_roles( # noqa: A003 # id cannot use the `immutable` wrapper because :meth:`new` changes the id when # handling collisions. This needs an "immutable after commit" handler - sa.Column(sa.BigInteger, autoincrement=False, nullable=False, primary_key=True), + sa.orm.mapped_column( + sa.BigInteger, autoincrement=False, nullable=False, primary_key=True + ), read={'all'}, ) #: URL target of this shortlink url = with_roles( - immutable(sa.Column(UrlType, nullable=False, index=True)), + immutable(sa.orm.mapped_column(UrlType, nullable=False, index=True)), read={'all'}, ) - #: Id of user who created this shortlink (optional) - user_id = sa.Column( - sa.Integer, sa.ForeignKey('user.id', ondelete='SET NULL'), nullable=True + #: Id of account that created this shortlink (optional) + created_by_id: Mapped[int | None] = sa.orm.mapped_column( + sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=True ) - #: User who created this shortlink (optional) - user: Mapped[Optional[User]] = sa.orm.relationship(User) + #: Account that created this shortlink (optional) + created_by: Mapped[Account | None] = relationship(Account) #: Is this link enabled? If not, render 410 Gone - enabled = sa.Column(sa.Boolean, nullable=False, default=True) + enabled = sa.orm.mapped_column(sa.Boolean, nullable=False, default=True) @hybrid_property def name(self) -> str: @@ -217,26 +227,27 @@ def name(self) -> str: return '' return bigint_to_name(self.id) - @name.setter - def name(self, value: Union[str, bytes]): + @name.inplace.setter + def _name_setter(self, value: str | bytes) -> None: """Set a name.""" self.id = name_to_bigint(value) - @name.comparator - def name(cls): # noqa: N805 # pylint: disable=no-self-argument + @name.inplace.comparator + @classmethod + def _name_comparator(cls): """Compare name to id in a SQL expression.""" return ShortLinkToBigIntComparator(cls.id) # --- Validators @sa.orm.validates('id') - def _validate_id_not_zero(self, key, value: int) -> int: # skipcq: PYL-R0201 + def _validate_id_not_zero(self, _key: str, value: int) -> int: if value == 0: raise ValueError("Id cannot be zero") return value @sa.orm.validates('url') - def _validate_url(self, key, value) -> str: # skipcq: PYL-R0201 + def _validate_url(self, _key: str, value: str) -> str: value = str(normalize_url(value)) # If URL hashes are added to the model, the value must be set here using # `url_blake2b160_hash(value)` @@ -252,12 +263,12 @@ def __repr__(self) -> str: @classmethod def new( cls, - url: Union[str, furl], + url: str | furl, *, - name: Optional[str] = None, + name: str | None = None, shorter: bool = False, reuse: Literal[False] = False, - actor: Optional[User] = None, + actor: Account | None = None, ) -> Shortlink: ... @@ -265,24 +276,24 @@ def new( @classmethod def new( cls, - url: Union[str, furl], + url: str | furl, *, name: Literal[None] = None, shorter: bool = False, reuse: Literal[True] = True, - actor: Optional[User] = None, + actor: Account | None = None, ) -> Shortlink: ... @classmethod def new( cls, - url: Union[str, furl], + url: str | furl, *, - name: Optional[str] = None, + name: str | None = None, shorter: bool = False, reuse: bool = False, - actor: Optional[User] = None, + actor: Account | None = None, ) -> Shortlink: """ Create a new shortlink. @@ -318,7 +329,7 @@ def new( if name: # User wants a custom name? Try using it, but no guarantee this will work try: - shortlink = cls(name=name, url=url, user=actor) + shortlink = cls(name=name, url=url, created_by=actor) shortlink.is_new = True # 1. Emit `BEGIN SAVEPOINT` savepoint = db.session.begin_nested() @@ -334,7 +345,7 @@ def new( return shortlink # Not a custom name. Keep trying ids until one succeeds - shortlink = cls(id=random_bigint(shorter), url=url, user=actor) + shortlink = cls(id=random_bigint(shorter), url=url, created_by=actor) shortlink.is_new = True while True: if profanity.contains_profanity(shortlink.name): @@ -365,9 +376,7 @@ def name_available(cls, name: str) -> bool: return False @classmethod - def get( - cls, name: Union[str, bytes], ignore_enabled: bool = False - ) -> Optional[Shortlink]: + def get(cls, name: str | bytes, ignore_enabled: bool = False) -> Shortlink | None: """ Get a shortlink by name, if existing and not disabled. @@ -377,7 +386,7 @@ def get( idv = name_to_bigint(name) except (ValueError, TypeError): return None - obj = db.session.get( # type: ignore[attr-defined] + obj = db.session.get( cls, idv, options=[sa.orm.load_only(cls.id, cls.url, cls.enabled)] ) if obj is not None and (ignore_enabled or obj.enabled): diff --git a/funnel/models/site_membership.py b/funnel/models/site_membership.py index a542f456d..2121e13dc 100644 --- a/funnel/models/site_membership.py +++ b/funnel/models/site_membership.py @@ -2,39 +2,38 @@ from __future__ import annotations -from typing import Set -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - from werkzeug.utils import cached_property -from ..typing import Mapped -from . import User, db, declared_attr, sa +from . import Mapped, Model, declared_attr, relationship, sa +from .account import Account from .helpers import reopen from .membership_mixin import ImmutableUserMembershipMixin __all__ = ['SiteMembership'] -class SiteMembership( - ImmutableUserMembershipMixin, - db.Model, # type: ignore[name-defined] -): +class SiteMembership(ImmutableUserMembershipMixin, Model): """Membership roles for users who are site administrators.""" __tablename__ = 'site_membership' - __allow_unmapped__ = True # List of is_role columns in this model - __data_columns__ = {'is_comment_moderator', 'is_user_moderator', 'is_site_editor'} + __data_columns__ = { + 'is_comment_moderator', + 'is_user_moderator', + 'is_site_editor', + 'is_sysadmin', + } __roles__ = { - 'all': { + 'member': { 'read': { 'urls', - 'user', + 'member', 'is_comment_moderator', 'is_user_moderator', 'is_site_editor', + 'is_sysadmin', } } } @@ -47,15 +46,21 @@ class SiteMembership( # Site admin roles (at least one must be True): #: Comment moderators can delete comments - is_comment_moderator: Mapped[bool] = sa.Column( + is_comment_moderator: Mapped[bool] = sa.orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: User moderators can suspend users - is_user_moderator: Mapped[bool] = sa.Column( + is_user_moderator: Mapped[bool] = sa.orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: Site editors can feature or reject projects - is_site_editor: Mapped[bool] = sa.Column(sa.Boolean, nullable=False, default=False) + is_site_editor: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + #: Sysadmins can manage technical settings + is_sysadmin: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) @declared_attr.directive @classmethod @@ -64,10 +69,11 @@ def __table_args__(cls) -> tuple: args = list(super().__table_args__) args.append( sa.CheckConstraint( - sa.or_( # type: ignore[arg-type] + sa.or_( cls.is_comment_moderator.is_(True), cls.is_user_moderator.is_(True), cls.is_site_editor.is_(True), + cls.is_sysadmin.is_(True), ), name='site_membership_has_role', ) @@ -78,17 +84,17 @@ def __repr__(self) -> str: """Return representation of membership.""" # pylint: disable=using-constant-test return ( - f'<{self.__class__.__name__} {self.subject!r} ' + f'<{self.__class__.__name__} {self.member!r} ' + ('active' if self.is_active else 'revoked') + '>' ) @cached_property - def offered_roles(self) -> Set[str]: + def offered_roles(self) -> set[str]: """ Roles offered by this membership record. - This property will typically not be used, as the ``User.is_*`` properties + This property will typically not be used, as the ``Account.is_*`` properties directly test the role columns. This property exists solely to satisfy the :attr:`offered_roles` membership ducktype. """ @@ -99,18 +105,20 @@ def offered_roles(self) -> Set[str]: roles.add('user_moderator') if self.is_site_editor: roles.add('site_editor') + if self.is_sysadmin: + roles.add('sysadmin') return roles -@reopen(User) -class __User: +@reopen(Account) +class __Account: # Singular, as only one can be active - active_site_membership = sa.orm.relationship( + active_site_membership: Mapped[SiteMembership] = relationship( SiteMembership, lazy='select', primaryjoin=sa.and_( - SiteMembership.user_id == User.id, # type: ignore[has-type] - SiteMembership.is_active, # type: ignore[arg-type] + SiteMembership.member_id == Account.id, # type: ignore[has-type] + SiteMembership.is_active, ), viewonly=True, uselist=False, @@ -140,6 +148,14 @@ def is_site_editor(self) -> bool: and self.active_site_membership.is_site_editor ) + @cached_property + def is_sysadmin(self) -> bool: + """Test if this user is a sysadmin.""" + return ( + self.active_site_membership is not None + and self.active_site_membership.is_sysadmin + ) + # site_admin means user has one or more of above roles @cached_property def is_site_admin(self) -> bool: diff --git a/funnel/models/sponsor_membership.py b/funnel/models/sponsor_membership.py index 5b64d344b..3bfb4ff34 100644 --- a/funnel/models/sponsor_membership.py +++ b/funnel/models/sponsor_membership.py @@ -2,21 +2,18 @@ from __future__ import annotations -from typing import Set -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - from werkzeug.utils import cached_property from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles -from . import Mapped, db, sa +from . import DynamicMapped, Mapped, Model, backref, db, relationship, sa +from .account import Account from .helpers import reopen from .membership_mixin import ( FrozenAttributionMixin, - ImmutableProfileMembershipMixin, + ImmutableUserMembershipMixin, ReorderMembershipMixin, ) -from .profile import Profile from .project import Project from .proposal import Proposal @@ -26,13 +23,12 @@ class ProjectSponsorMembership( # type: ignore[misc] FrozenAttributionMixin, ReorderMembershipMixin, - ImmutableProfileMembershipMixin, - db.Model, # type: ignore[name-defined] + ImmutableUserMembershipMixin, + Model, ): """Sponsor of a project.""" __tablename__ = 'project_sponsor_membership' - __allow_unmapped__ = True # List of data columns in this model that must be copied into revisions __data_columns__ = ('seq', 'is_promoted', 'label', 'title') @@ -42,21 +38,21 @@ class ProjectSponsorMembership( # type: ignore[misc] 'read': { 'is_promoted', 'label', - 'profile', + 'member', 'project', 'seq', 'title', 'urls', }, 'call': {'url_for'}, - }, + } } __datasets__ = { 'primary': { 'is_promoted', 'label', 'offered_roles', - 'profile', + 'member', 'project', 'seq', 'title', @@ -67,7 +63,7 @@ class ProjectSponsorMembership( # type: ignore[misc] 'is_promoted', 'label', 'offered_roles', - 'profile', + 'member', 'seq', 'title', 'urls', @@ -84,14 +80,14 @@ class ProjectSponsorMembership( # type: ignore[misc] }, } - revoke_on_subject_delete = False + revoke_on_member_delete = False - project_id: Mapped[int] = sa.Column( + project_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False ) - project: Mapped[Project] = sa.orm.relationship( + project: Mapped[Project] = relationship( Project, - backref=sa.orm.backref( + backref=backref( 'all_sponsor_memberships', lazy='dynamic', cascade='all', @@ -104,11 +100,11 @@ class ProjectSponsorMembership( # type: ignore[misc] #: Is this sponsor being promoted for commercial reasons? Projects may have a legal #: obligation to reveal this. This column records a declaration from the project. - is_promoted = immutable(sa.Column(sa.Boolean, nullable=False)) + is_promoted = immutable(sa.orm.mapped_column(sa.Boolean, nullable=False)) #: Optional label, indicating the type of sponsor label = immutable( - sa.Column( + sa.orm.mapped_column( sa.Unicode, sa.CheckConstraint( "label <> ''", name='project_sponsor_membership_label_check' @@ -123,20 +119,20 @@ class ProjectSponsorMembership( # type: ignore[misc] # a page id reference column whenever that model is ready. @cached_property - def offered_roles(self) -> Set[str]: + def offered_roles(self) -> set[str]: """Return empty set as this membership does not offer any roles on Project.""" return set() @reopen(Project) class __Project: - sponsor_memberships = with_roles( - sa.orm.relationship( + sponsor_memberships: DynamicMapped[ProjectSponsorMembership] = with_roles( + relationship( ProjectSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( ProjectSponsorMembership.project_id == Project.id, - ProjectSponsorMembership.is_active, # type: ignore[arg-type] + ProjectSponsorMembership.is_active, ), order_by=ProjectSponsorMembership.seq, viewonly=True, @@ -146,22 +142,23 @@ class __Project: @with_roles(read={'all'}) @cached_property - def has_sponsors(self): + def has_sponsors(self) -> bool: return db.session.query(self.sponsor_memberships.exists()).scalar() - sponsors = DynamicAssociationProxy('sponsor_memberships', 'profile') + sponsors = DynamicAssociationProxy('sponsor_memberships', 'member') +# FIXME: Replace this with existing proposal collaborator as they're now both related +# to "account" class ProposalSponsorMembership( # type: ignore[misc] FrozenAttributionMixin, ReorderMembershipMixin, - ImmutableProfileMembershipMixin, - db.Model, # type: ignore[name-defined] + ImmutableUserMembershipMixin, + Model, ): """Sponsor of a proposal.""" __tablename__ = 'proposal_sponsor_membership' - __allow_unmapped__ = True # List of data columns in this model that must be copied into revisions __data_columns__ = ('seq', 'is_promoted', 'label', 'title') @@ -171,7 +168,7 @@ class ProposalSponsorMembership( # type: ignore[misc] 'read': { 'is_promoted', 'label', - 'profile', + 'member', 'proposal', 'seq', 'title', @@ -185,7 +182,7 @@ class ProposalSponsorMembership( # type: ignore[misc] 'is_promoted', 'label', 'offered_roles', - 'profile', + 'member', 'proposal', 'seq', 'title', @@ -196,7 +193,7 @@ class ProposalSponsorMembership( # type: ignore[misc] 'is_promoted', 'label', 'offered_roles', - 'profile', + 'member', 'seq', 'title', 'urls', @@ -213,14 +210,14 @@ class ProposalSponsorMembership( # type: ignore[misc] }, } - revoke_on_subject_delete = False + revoke_on_member_delete = False - proposal_id: Mapped[int] = sa.Column( + proposal_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False ) - proposal: Mapped[Proposal] = sa.orm.relationship( + proposal: Mapped[Proposal] = relationship( Proposal, - backref=sa.orm.backref( + backref=backref( 'all_sponsor_memberships', lazy='dynamic', cascade='all', @@ -233,11 +230,11 @@ class ProposalSponsorMembership( # type: ignore[misc] #: Is this sponsor being promoted for commercial reasons? Proposals may have a legal #: obligation to reveal this. This column records a declaration from the proposal. - is_promoted = immutable(sa.Column(sa.Boolean, nullable=False)) + is_promoted = immutable(sa.orm.mapped_column(sa.Boolean, nullable=False)) #: Optional label, indicating the type of sponsor label = immutable( - sa.Column( + sa.orm.mapped_column( sa.Unicode, sa.CheckConstraint( "label <> ''", name='proposal_sponsor_membership_label_check' @@ -247,20 +244,20 @@ class ProposalSponsorMembership( # type: ignore[misc] ) @cached_property - def offered_roles(self) -> Set[str]: + def offered_roles(self) -> set[str]: """Return empty set as this membership does not offer any roles on Proposal.""" return set() @reopen(Proposal) class __Proposal: - sponsor_memberships = with_roles( - sa.orm.relationship( + sponsor_memberships: DynamicMapped[ProposalSponsorMembership] = with_roles( + relationship( ProposalSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( ProposalSponsorMembership.proposal_id == Proposal.id, - ProposalSponsorMembership.is_active, # type: ignore[arg-type] + ProposalSponsorMembership.is_active, ), order_by=ProposalSponsorMembership.seq, viewonly=True, @@ -270,45 +267,49 @@ class __Proposal: @with_roles(read={'all'}) @cached_property - def has_sponsors(self): + def has_sponsors(self) -> bool: return db.session.query(self.sponsor_memberships.exists()).scalar() - sponsors = DynamicAssociationProxy('sponsor_memberships', 'profile') + sponsors = DynamicAssociationProxy('sponsor_memberships', 'member') -@reopen(Profile) -class __Profile: +@reopen(Account) +class __Account: # pylint: disable=invalid-unary-operand-type - noninvite_project_sponsor_memberships = sa.orm.relationship( + noninvite_project_sponsor_memberships: DynamicMapped[ + ProjectSponsorMembership + ] = relationship( ProjectSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectSponsorMembership.profile_id == Profile.id, - ~ProjectSponsorMembership.is_invite, # type: ignore[operator] + ProjectSponsorMembership.member_id == Account.id, + ~ProjectSponsorMembership.is_invite, ), order_by=ProjectSponsorMembership.granted_at.desc(), viewonly=True, ) - project_sponsor_memberships = sa.orm.relationship( + project_sponsor_memberships: DynamicMapped[ProjectSponsorMembership] = relationship( ProjectSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectSponsorMembership.profile_id == Profile.id, - ProjectSponsorMembership.is_active, # type: ignore[arg-type] + ProjectSponsorMembership.member_id == Account.id, + ProjectSponsorMembership.is_active, ), order_by=ProjectSponsorMembership.granted_at.desc(), viewonly=True, ) - project_sponsor_membership_invites = with_roles( - sa.orm.relationship( + project_sponsor_membership_invites: DynamicMapped[ + ProjectSponsorMembership + ] = with_roles( + relationship( ProjectSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectSponsorMembership.profile_id == Profile.id, - ProjectSponsorMembership.is_invite, # type: ignore[arg-type] - ProjectSponsorMembership.revoked_at.is_(None), # type: ignore[has-type] + ProjectSponsorMembership.member_id == Account.id, + ProjectSponsorMembership.is_invite, + ProjectSponsorMembership.revoked_at.is_(None), ), order_by=ProjectSponsorMembership.granted_at.desc(), viewonly=True, @@ -316,38 +317,42 @@ class __Profile: read={'admin'}, ) - noninvite_proposal_sponsor_memberships = sa.orm.relationship( + noninvite_proposal_sponsor_memberships: DynamicMapped[ + ProposalSponsorMembership + ] = relationship( ProposalSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalSponsorMembership.profile_id == Profile.id, - ~ProposalSponsorMembership.is_invite, # type: ignore[operator] + ProposalSponsorMembership.member_id == Account.id, + ~ProposalSponsorMembership.is_invite, ), order_by=ProposalSponsorMembership.granted_at.desc(), viewonly=True, ) - proposal_sponsor_memberships = sa.orm.relationship( + proposal_sponsor_memberships: DynamicMapped[ + ProposalSponsorMembership + ] = relationship( ProposalSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalSponsorMembership.profile_id == Profile.id, - ProposalSponsorMembership.is_active, # type: ignore[arg-type] + ProposalSponsorMembership.member_id == Account.id, + ProposalSponsorMembership.is_active, ), order_by=ProposalSponsorMembership.granted_at.desc(), viewonly=True, ) - proposal_sponsor_membership_invites = with_roles( - sa.orm.relationship( + proposal_sponsor_membership_invites: DynamicMapped[ + ProposalSponsorMembership + ] = with_roles( + relationship( ProposalSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalSponsorMembership.profile_id == Profile.id, - ProposalSponsorMembership.is_invite, # type: ignore[arg-type] - ProposalSponsorMembership.revoked_at.is_( # type: ignore[has-type] - None - ), + ProposalSponsorMembership.member_id == Account.id, + ProposalSponsorMembership.is_invite, + ProposalSponsorMembership.revoked_at.is_(None), ), order_by=ProposalSponsorMembership.granted_at.desc(), viewonly=True, @@ -364,9 +369,9 @@ class __Profile: ) -Profile.__active_membership_attrs__.update( +Account.__active_membership_attrs__.update( {'project_sponsor_memberships', 'proposal_sponsor_memberships'} ) -Profile.__noninvite_membership_attrs__.update( +Account.__noninvite_membership_attrs__.update( {'noninvite_project_sponsor_memberships', 'noninvite_proposal_sponsor_memberships'} ) diff --git a/funnel/models/sync_ticket.py b/funnel/models/sync_ticket.py index 8d769a241..29f99708d 100644 --- a/funnel/models/sync_ticket.py +++ b/funnel/models/sync_ticket.py @@ -2,19 +2,32 @@ from __future__ import annotations -from typing import Iterable, List, Optional -from uuid import UUID # noqa: F401 # pylint: disable=unused-import import base64 import os +from collections.abc import Iterable, Sequence +from typing import Any from coaster.sqlalchemy import LazyRoleSet -from . import BaseMixin, BaseScopedNameMixin, Mapped, UuidMixin, db, sa, with_roles +from . import ( + BaseMixin, + BaseScopedNameMixin, + DynamicMapped, + Mapped, + Model, + Query, + UuidMixin, + backref, + db, + relationship, + sa, + with_roles, +) +from .account import Account, AccountEmail from .email_address import EmailAddress, EmailAddressMixin from .helpers import reopen from .project import Project from .project_membership import project_child_role_map -from .user import User, UserEmail __all__ = [ 'SyncTicket', @@ -40,7 +53,7 @@ def make_private_key(): ticket_event_ticket_type = sa.Table( 'ticket_event_ticket_type', - db.Model.metadata, # type: ignore[has-type] + Model.metadata, sa.Column( 'ticket_event_id', sa.Integer, @@ -61,7 +74,9 @@ def make_private_key(): class GetTitleMixin(BaseScopedNameMixin): @classmethod - def get(cls, parent, name=None, title=None): + def get( + cls, parent: Any, name: str | None = None, title: str | None = None + ) -> GetTitleMixin | None: if not bool(name) ^ bool(title): raise TypeError("Expects name xor title") if name: @@ -69,9 +84,13 @@ def get(cls, parent, name=None, title=None): return cls.query.filter_by(parent=parent, title=title).one_or_none() @classmethod - def upsert( # pylint: disable=arguments-renamed - cls, parent, current_name=None, current_title=None, **fields - ): + def upsert( # type: ignore[override] # pylint: disable=arguments-renamed + cls, + parent: Any, + current_name: str | None = None, + current_title: str | None = None, + **fields, + ) -> GetTitleMixin: instance = cls.get(parent, current_name, current_title) if instance is not None: instance._set_fields(fields) # pylint: disable=protected-access @@ -82,7 +101,7 @@ def upsert( # pylint: disable=arguments-renamed return instance -class TicketEvent(GetTitleMixin, db.Model): # type: ignore[name-defined] +class TicketEvent(GetTitleMixin, Model): """ A discrete event under a project that a ticket grants access to. @@ -94,27 +113,26 @@ class TicketEvent(GetTitleMixin, db.Model): # type: ignore[name-defined] """ __tablename__ = 'ticket_event' - __allow_unmapped__ = True - project_id = sa.Column(sa.Integer, sa.ForeignKey('project.id'), nullable=False) + project_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('project.id'), nullable=False + ) project: Mapped[Project] = with_roles( - sa.orm.relationship( - Project, backref=sa.orm.backref('ticket_events', cascade='all') - ), + relationship(Project, backref=backref('ticket_events', cascade='all')), rw={'project_promoter'}, grants_via={None: project_child_role_map}, ) parent: Mapped[Project] = sa.orm.synonym('project') - ticket_types: Mapped[List[TicketType]] = with_roles( - sa.orm.relationship( + ticket_types: Mapped[list[TicketType]] = with_roles( + relationship( 'TicketType', secondary=ticket_event_ticket_type, back_populates='ticket_events', ), rw={'project_promoter'}, ) - ticket_participants: Mapped[List[TicketParticipant]] = with_roles( - sa.orm.relationship( + ticket_participants: DynamicMapped[TicketParticipant] = with_roles( + relationship( 'TicketParticipant', secondary='ticket_event_participant', backref='ticket_events', @@ -123,7 +141,7 @@ class TicketEvent(GetTitleMixin, db.Model): # type: ignore[name-defined] rw={'project_promoter'}, ) badge_template = with_roles( - sa.Column(sa.Unicode(250), nullable=True), rw={'project_promoter'} + sa.orm.mapped_column(sa.Unicode(250), nullable=True), rw={'project_promoter'} ) __table_args__ = ( @@ -142,7 +160,7 @@ class TicketEvent(GetTitleMixin, db.Model): # type: ignore[name-defined] } -class TicketType(GetTitleMixin, db.Model): # type: ignore[name-defined] +class TicketType(GetTitleMixin, Model): """ A ticket type that can grant access to multiple events within a project. @@ -150,19 +168,18 @@ class TicketType(GetTitleMixin, db.Model): # type: ignore[name-defined] """ __tablename__ = 'ticket_type' - __allow_unmapped__ = True - project_id = sa.Column(sa.Integer, sa.ForeignKey('project.id'), nullable=False) + project_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('project.id'), nullable=False + ) project: Mapped[Project] = with_roles( - sa.orm.relationship( - Project, backref=sa.orm.backref('ticket_types', cascade='all') - ), + relationship(Project, backref=backref('ticket_types', cascade='all')), rw={'project_promoter'}, grants_via={None: project_child_role_map}, ) parent: Mapped[Project] = sa.orm.synonym('project') - ticket_events: Mapped[List[TicketEvent]] = with_roles( - sa.orm.relationship( + ticket_events: Mapped[list[TicketEvent]] = with_roles( + relationship( TicketEvent, secondary=ticket_event_ticket_type, back_populates='ticket_types', @@ -186,64 +203,62 @@ class TicketType(GetTitleMixin, db.Model): # type: ignore[name-defined] } -class TicketParticipant( - EmailAddressMixin, - UuidMixin, - BaseMixin, - db.Model, # type: ignore[name-defined] -): +class TicketParticipant(EmailAddressMixin, UuidMixin, BaseMixin, Model): """A participant in one or more events, synced from an external ticket source.""" __tablename__ = 'ticket_participant' - __allow_unmapped__ = True - __email_optional__ = False - __email_for__ = 'user' + __email_optional__ = True + __email_for__ = 'participant' fullname = with_roles( - sa.Column(sa.Unicode(80), nullable=False), - read={'promoter', 'subject', 'scanner'}, + sa.orm.mapped_column(sa.Unicode(80), nullable=False), + read={'promoter', 'member', 'scanner'}, ) #: Unvalidated phone number phone = with_roles( - sa.Column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + sa.orm.mapped_column(sa.Unicode(80), nullable=True), + read={'promoter', 'member', 'scanner'}, ) #: Unvalidated Twitter id twitter = with_roles( - sa.Column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + sa.orm.mapped_column(sa.Unicode(80), nullable=True), + read={'promoter', 'member', 'scanner'}, ) #: Job title job_title = with_roles( - sa.Column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + sa.orm.mapped_column(sa.Unicode(80), nullable=True), + read={'promoter', 'member', 'scanner'}, ) #: Company company = with_roles( - sa.Column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + sa.orm.mapped_column(sa.Unicode(80), nullable=True), + read={'promoter', 'member', 'scanner'}, ) #: Participant's city city = with_roles( - sa.Column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + sa.orm.mapped_column(sa.Unicode(80), nullable=True), + read={'promoter', 'member', 'scanner'}, ) # public key - puk = sa.Column( + puk = sa.orm.mapped_column( sa.Unicode(44), nullable=False, default=make_public_key, unique=True ) - key = sa.Column( + key = sa.orm.mapped_column( sa.Unicode(44), nullable=False, default=make_private_key, unique=True ) - badge_printed = sa.Column(sa.Boolean, default=False, nullable=False) - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) - user: Mapped[Optional[User]] = sa.orm.relationship( - User, backref=sa.orm.backref('ticket_participants', cascade='all') + badge_printed = sa.orm.mapped_column(sa.Boolean, default=False, nullable=False) + participant_id: Mapped[int | None] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=True + ) + participant: Mapped[Account | None] = relationship( + Account, backref=backref('ticket_participants', cascade='all') + ) + project_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('project.id'), nullable=False ) - project_id = sa.Column(sa.Integer, sa.ForeignKey('project.id'), nullable=False) project: Mapped[Project] = with_roles( - sa.orm.relationship(Project, back_populates='ticket_participants'), - read={'promoter', 'subject', 'scanner'}, + relationship(Project, back_populates='ticket_participants'), + read={'promoter', 'member', 'scanner'}, grants_via={None: project_child_role_map}, ) @@ -253,17 +268,17 @@ class TicketParticipant( # `with_roles`. Instead, we have to specify the roles that can access it in here: __roles__ = { 'promoter': {'read': {'email'}}, - 'subject': {'read': {'email'}}, + 'member': {'read': {'email'}}, 'scanner': {'read': {'email'}}, } 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: - roles.add('subject') + if actor == self.participant: + roles.add('member') cx = ContactExchange.query.get((actor.id, self.id)) if cx is not None: roles.add('scanner') @@ -271,63 +286,66 @@ def roles_for( @property def avatar(self): - return self.user.avatar if self.user else '' + return self.participant.logo_url if self.participant else '' with_roles(avatar, read={'all'}) @property - def has_public_profile(self): - return self.user.has_public_profile if self.user else False + def has_public_profile(self) -> bool: + return self.participant.has_public_profile if self.participant else False with_roles(has_public_profile, read={'all'}) @property - def profile_url(self): - return ( - self.user.profile.url_for() - if self.user and self.user.has_public_profile - else None - ) + def profile_url(self) -> str | None: + return self.participant.profile_url if self.participant else None with_roles(profile_url, read={'all'}) @classmethod - def get(cls, current_project, current_email): + def get( + cls, current_project: Project, current_email: str + ) -> TicketParticipant | None: return cls.query.filter_by( project=current_project, email_address=EmailAddress.get(current_email) ).one_or_none() @classmethod - def upsert(cls, current_project, current_email, **fields): + def upsert( + cls, current_project: Project, current_email: str, **fields + ) -> TicketParticipant: ticket_participant = cls.get(current_project, current_email) - useremail = UserEmail.get(current_email) - if useremail is not None: - user = useremail.user + accountemail = AccountEmail.get(current_email) + if accountemail is not None: + participant = accountemail.account else: - user = None + participant = None if ticket_participant is not None: - ticket_participant.user = user + ticket_participant.participant = participant ticket_participant._set_fields(fields) # pylint: disable=protected-access else: with db.session.no_autoflush: ticket_participant = cls( - project=current_project, user=user, email=current_email, **fields + project=current_project, + participant=participant, + email=current_email, + **fields, ) db.session.add(ticket_participant) return ticket_participant - def add_events(self, ticket_events): + def add_events(self, ticket_events: Iterable[TicketEvent]) -> None: for ticket_event in ticket_events: if ticket_event not in self.ticket_events: self.ticket_events.append(ticket_event) - def remove_events(self, ticket_events): + def remove_events(self, ticket_events: Iterable[TicketEvent]) -> None: for ticket_event in ticket_events: if ticket_event in self.ticket_events: self.ticket_events.remove(ticket_event) @classmethod - def checkin_list(cls, ticket_event): + def checkin_list(cls, ticket_event: TicketEvent) -> list: # TODO: List type? """ Return ticket participant details as a comma separated string. @@ -351,14 +369,16 @@ def checkin_list(cls, ticket_event): .join(TicketType, SyncTicket.ticket_type_id == TicketType.id) .filter(SyncTicket.ticket_participant_id == TicketParticipant.id) .label('ticket_type_titles'), - cls.user_id.isnot(None).label('has_user'), + cls.participant_id.is_not(None).label('has_user'), ) .select_from(TicketParticipant) .join( TicketEventParticipant, TicketParticipant.id == TicketEventParticipant.ticket_participant_id, ) - .join(EmailAddress, EmailAddress.id == TicketParticipant.email_address_id) + .outerjoin( + EmailAddress, EmailAddress.id == TicketParticipant.email_address_id + ) .outerjoin( SyncTicket, TicketParticipant.id == SyncTicket.ticket_participant_id ) @@ -368,37 +388,36 @@ def checkin_list(cls, ticket_event): return query.all() -class TicketEventParticipant(BaseMixin, db.Model): # type: ignore[name-defined] +class TicketEventParticipant(BaseMixin, Model): """Join model between :class:`TicketParticipant` and :class:`TicketEvent`.""" __tablename__ = 'ticket_event_participant' - __allow_unmapped__ = True - ticket_participant_id = sa.Column( + ticket_participant_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_participant.id'), nullable=False ) - ticket_participant: Mapped[TicketParticipant] = sa.orm.relationship( + ticket_participant: Mapped[TicketParticipant] = relationship( TicketParticipant, - backref=sa.orm.backref( + backref=backref( 'ticket_event_participants', cascade='all', overlaps='ticket_events,ticket_participants', ), overlaps='ticket_events,ticket_participants', ) - ticket_event_id = sa.Column( + ticket_event_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_event.id'), nullable=False ) - ticket_event: Mapped[TicketEvent] = sa.orm.relationship( + ticket_event: Mapped[TicketEvent] = relationship( TicketEvent, - backref=sa.orm.backref( + backref=backref( 'ticket_event_participants', cascade='all', overlaps='ticket_events,ticket_participants', ), overlaps='ticket_events,ticket_participants', ) - checked_in = sa.Column(sa.Boolean, default=False, nullable=False) + checked_in = sa.orm.mapped_column(sa.Boolean, default=False, nullable=False) __table_args__ = ( # Uses a custom name that is not as per convention because the default name is @@ -411,7 +430,9 @@ class TicketEventParticipant(BaseMixin, db.Model): # type: ignore[name-defined] ) @classmethod - def get(cls, ticket_event, participant_uuid_b58): + def get( + cls, ticket_event: TicketEvent, participant_uuid_b58: str + ) -> TicketEventParticipant | None: return ( cls.query.join(TicketParticipant) .filter( @@ -422,29 +443,28 @@ def get(cls, ticket_event, participant_uuid_b58): ) -class TicketClient(BaseMixin, db.Model): # type: ignore[name-defined] +class TicketClient(BaseMixin, Model): __tablename__ = 'ticket_client' - __allow_unmapped__ = True name = with_roles( - sa.Column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) client_eventid = with_roles( - sa.Column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) clientid = with_roles( - sa.Column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) client_secret = with_roles( - sa.Column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) client_access_token = with_roles( - sa.Column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + ) + project_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('project.id'), nullable=False ) - project_id = sa.Column(sa.Integer, sa.ForeignKey('project.id'), nullable=False) project = with_roles( - sa.orm.relationship( - Project, backref=sa.orm.backref('ticket_clients', cascade='all') - ), + relationship(Project, backref=backref('ticket_clients', cascade='all')), rw={'project_promoter'}, grants_via={None: project_child_role_map}, ) @@ -492,43 +512,46 @@ def import_from_list(self, ticket_list): ticket.ticket_participant.add_events(ticket_type.ticket_events) -class SyncTicket(BaseMixin, db.Model): # type: ignore[name-defined] +class SyncTicket(BaseMixin, Model): """Model for a ticket that was bought elsewhere, like Boxoffice or Explara.""" __tablename__ = 'sync_ticket' - __allow_unmapped__ = True - ticket_no = sa.Column(sa.Unicode(80), nullable=False) - order_no = sa.Column(sa.Unicode(80), nullable=False) - ticket_type_id = sa.Column( + ticket_no = sa.orm.mapped_column(sa.Unicode(80), nullable=False) + order_no = sa.orm.mapped_column(sa.Unicode(80), nullable=False) + ticket_type_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_type.id'), nullable=False ) - ticket_type: Mapped[TicketType] = sa.orm.relationship( - TicketType, backref=sa.orm.backref('sync_tickets', cascade='all') + ticket_type: Mapped[TicketType] = relationship( + TicketType, backref=backref('sync_tickets', cascade='all') ) - ticket_participant_id = sa.Column( + ticket_participant_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_participant.id'), nullable=False ) - ticket_participant: Mapped[TicketParticipant] = sa.orm.relationship( + ticket_participant: Mapped[TicketParticipant] = relationship( TicketParticipant, - backref=sa.orm.backref('sync_tickets', cascade='all'), + backref=backref('sync_tickets', cascade='all'), ) - ticket_client_id = sa.Column( + ticket_client_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_client.id'), nullable=False ) - ticket_client: Mapped[TicketClient] = sa.orm.relationship( - TicketClient, backref=sa.orm.backref('sync_tickets', cascade='all') + ticket_client: Mapped[TicketClient] = relationship( + TicketClient, backref=backref('sync_tickets', cascade='all') ) __table_args__ = (sa.UniqueConstraint('ticket_client_id', 'order_no', 'ticket_no'),) @classmethod - def get(cls, ticket_client, order_no, ticket_no): + def get( + cls, ticket_client: TicketClient, order_no: str, ticket_no: str + ) -> SyncTicket | None: return cls.query.filter_by( ticket_client=ticket_client, order_no=order_no, ticket_no=ticket_no ).one_or_none() @classmethod - def upsert(cls, ticket_client, order_no, ticket_no, **fields): + def upsert( + cls, ticket_client: TicketClient, order_no: str, ticket_no: str, **fields + ) -> SyncTicket: """ Update or insert ticket details. @@ -542,7 +565,7 @@ def upsert(cls, ticket_client, order_no, ticket_no, **fields): fields.pop('ticket_client', None) fields.pop('order_no', None) fields.pop('ticket_no', None) - ticket = SyncTicket( + ticket = cls( ticket_client=ticket_client, order_no=order_no, ticket_no=ticket_no, @@ -557,21 +580,39 @@ def upsert(cls, ticket_client, order_no, ticket_no, **fields): @reopen(Project) class __Project: # XXX: This relationship exposes an edge case in RoleMixin. It previously expected - # TicketParticipant.user to be unique per project, meaning one user could have one - # participant ticket only. This is not guaranteed by the model as tickets are unique - # per email address per ticket type, and one user can have (a) two email addresses - # with tickets, or (b) tickets of different types. RoleMixin has since been patched - # to look for the first matching record (.first() instead of .one()). This may - # expose a new edge case in future in case the TicketParticipant model adds an - # `offered_roles` method, as only the first matching record's method will be called - ticket_participants = with_roles( - sa.orm.relationship( + # TicketParticipant.participant to be unique per project, meaning one user could + # have one participant ticket only. This is not guaranteed by the model as tickets + # are unique per email address per ticket type, and one user can have (a) two email + # addresses with tickets, or (b) tickets of different types. RoleMixin has since + # been patched to look for the first matching record (.first() instead of .one()). + # This may expose a new edge case in future in case the TicketParticipant model adds + # an `offered_roles` method, as only the first matching record's method will be + # called + ticket_participants: DynamicMapped[TicketParticipant] = with_roles( + relationship( TicketParticipant, lazy='dynamic', cascade='all', back_populates='project' ), - grants_via={'user': {'participant'}}, + grants_via={ + 'participant': {'participant', 'project_participant', 'ticket_participant'} + }, ) +@reopen(Account) +class __Account: + @property + def ticket_followers(self) -> Query[Account]: + """All users with a ticket in a project.""" + return ( + Account.query.filter(Account.state.ACTIVE) + .join(TicketParticipant, TicketParticipant.participant_id == Account.id) + .join(Project, TicketParticipant.project_id == Project.id) + .filter(Project.state.PUBLISHED, Project.account == self) + ) + + with_roles(ticket_followers, grants={'follower'}) + + # Tail imports to avoid cyclic dependency errors, for symbols used only in methods # pylint: disable=wrong-import-position from .contact_exchange import ContactExchange # isort:skip diff --git a/funnel/models/types.py b/funnel/models/types.py new file mode 100644 index 000000000..dfd6abe64 --- /dev/null +++ b/funnel/models/types.py @@ -0,0 +1,63 @@ +"""Python to SQLAlchemy type mappings.""" + +from typing import Annotated, TypeAlias + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import mapped_column +from sqlalchemy_json import mutable_json_type + +from coaster.sqlalchemy import ( + bigint, + int_pkey, + smallint, + timestamp, + timestamp_now, + uuid4_pkey, +) + +__all__ = [ + 'int_pkey', + 'uuid4_pkey', + 'bigint', + 'smallint', + 'timestamp', + 'timestamp_now', + 'unicode', + 'text', + 'jsonb', + 'jsonb_dict', + 'char2', + 'char3', + 'str3', + 'str16', +] + +unicode: TypeAlias = Annotated[str, mapped_column(sa.Unicode())] +text: TypeAlias = Annotated[str, mapped_column(sa.UnicodeText())] +jsonb: TypeAlias = Annotated[ + dict, + sa.orm.mapped_column( + # FIXME: mutable_json_type assumes `dict|list`, not just `dict` + mutable_json_type( + dbtype=sa.JSON().with_variant(postgresql.JSONB, 'postgresql'), nested=True + ) + ), +] +jsonb_dict: TypeAlias = Annotated[ + dict, + sa.orm.mapped_column( + # FIXME: mutable_json_type assumes `dict|list`, not just `dict` + mutable_json_type( + dbtype=sa.JSON().with_variant(postgresql.JSONB, 'postgresql'), nested=True + ), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), +] + +# Specialised types +char2: TypeAlias = Annotated[str, mapped_column(sa.CHAR(2))] +char3: TypeAlias = Annotated[str, mapped_column(sa.CHAR(3))] +str3: TypeAlias = Annotated[str, mapped_column(sa.Unicode(3))] +str16: TypeAlias = Annotated[str, mapped_column(sa.Unicode(16))] diff --git a/funnel/models/typing.py b/funnel/models/typing.py new file mode 100644 index 000000000..694350b24 --- /dev/null +++ b/funnel/models/typing.py @@ -0,0 +1,49 @@ +"""Union types for models with shared functionality.""" + +from typing import Union + +from .account import Account, AccountOldId, Team +from .auth_client import AuthClient +from .comment import Comment, Commentset +from .label import Label +from .login_session import LoginSession +from .membership_mixin import ImmutableMembershipMixin +from .moderation import CommentModeratorReport +from .project import Project +from .proposal import Proposal +from .rsvp import Rsvp +from .session import Session +from .sync_ticket import TicketParticipant +from .update import Update +from .venue import Venue, VenueRoom + +__all__ = ['UuidModelUnion', 'SearchModelUnion', 'MarkdownModelUnion'] + +# All models with a `uuid` attr +UuidModelUnion = Union[ + Account, + AccountOldId, + AuthClient, + Comment, + CommentModeratorReport, + Commentset, + ImmutableMembershipMixin, + LoginSession, + Project, + Proposal, + Rsvp, + Session, + Team, + TicketParticipant, + Update, + Venue, + VenueRoom, +] + +# All models with a `search_vector` attr +SearchModelUnion = Union[Account, Comment, Label, Project, Proposal, Session, Update] + +# All models with one or more markdown composite columns +MarkdownModelUnion = Union[ + Account, Comment, Project, Proposal, Session, Update, Venue, VenueRoom +] diff --git a/funnel/models/update.py b/funnel/models/update.py index 0a57e313c..68a28157d 100644 --- a/funnel/models/update.py +++ b/funnel/models/update.py @@ -2,35 +2,36 @@ from __future__ import annotations -from typing import Iterable, Optional +from collections.abc import Sequence from sqlalchemy.orm import Query as BaseQuery from baseframe import __ -from coaster.sqlalchemy import ( - LazyRoleSet, - Query, - StateManager, - auto_init_default, - with_roles, -) +from coaster.sqlalchemy import LazyRoleSet, StateManager, auto_init_default, with_roles from coaster.utils import LabeledEnum from . import ( BaseScopedIdNameMixin, - Commentset, Mapped, - MarkdownCompositeDocument, - Project, + Model, + Query, TimestampMixin, TSVectorType, - User, UuidMixin, + backref, db, + relationship, sa, ) -from .comment import SET_TYPE -from .helpers import add_search_trigger, reopen, visual_field_delimiter +from .account import Account +from .comment import SET_TYPE, Commentset +from .helpers import ( + MarkdownCompositeDocument, + add_search_trigger, + reopen, + visual_field_delimiter, +) +from .project import Project __all__ = ['Update'] @@ -46,16 +47,10 @@ class VISIBILITY_STATE(LabeledEnum): # noqa: N801 RESTRICTED = (2, 'restricted', __("Restricted")) -class Update( - UuidMixin, - BaseScopedIdNameMixin, - TimestampMixin, - db.Model, # type: ignore[name-defined] -): +class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, Model): __tablename__ = 'update' - __allow_unmapped__ = True - _visibility_state = sa.Column( + _visibility_state = sa.orm.mapped_column( 'visibility_state', sa.SmallInteger, StateManager.check_constraint('visibility_state', VISIBILITY_STATE), @@ -67,7 +62,7 @@ class Update( '_visibility_state', VISIBILITY_STATE, doc="Visibility state" ) - _state = sa.Column( + _state = sa.orm.mapped_column( 'state', sa.SmallInteger, StateManager.check_constraint('state', UPDATE_STATE), @@ -77,24 +72,24 @@ class Update( ) state = StateManager('_state', UPDATE_STATE, doc="Update state") - user_id = sa.Column( - sa.Integer, sa.ForeignKey('user.id'), nullable=False, index=True + created_by_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False, index=True ) - user = with_roles( - sa.orm.relationship( - User, - backref=sa.orm.backref('updates', lazy='dynamic'), - foreign_keys=[user_id], + created_by: Mapped[Account] = with_roles( + relationship( + Account, + backref=backref('updates_created', lazy='dynamic'), + foreign_keys=[created_by_id], ), read={'all'}, grants={'creator'}, ) - project_id = sa.Column( + project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False, index=True ) project: Mapped[Project] = with_roles( - sa.orm.relationship(Project, backref=sa.orm.backref('updates', lazy='dynamic')), + relationship(Project, backref=backref('updates', lazy='dynamic')), read={'all'}, datasets={'primary'}, grants_via={ @@ -107,82 +102,103 @@ class Update( ) parent: Mapped[Project] = sa.orm.synonym('project') - body = MarkdownCompositeDocument.create('body', nullable=False) + # Relationship to project that exists only when the Update is not restricted, for + # the purpose of inheriting the account_participant role. We do this because + # RoleMixin does not have a mechanism for conditional grant of roles. A relationship + # marked as `grants_via` will always grant the role unconditionally, so the only + # control at the moment is to make the relationship itself conditional. The affected + # mechanism is not `roles_for` but `actors_with`, which is currently not meant to be + # redefined in a subclass + _project_when_unrestricted: Mapped[Project] = with_roles( + relationship( + Project, + viewonly=True, + uselist=False, + primaryjoin=sa.and_( + project_id == Project.id, _visibility_state == VISIBILITY_STATE.PUBLIC + ), + ), + grants_via={None: {'account_participant': 'account_participant'}}, + ) + + body, body_text, body_html = MarkdownCompositeDocument.create( + 'body', nullable=False + ) #: Update number, for Project updates, assigned when the update is published number = with_roles( - sa.Column(sa.Integer, nullable=True, default=None), read={'all'} + sa.orm.mapped_column(sa.Integer, nullable=True, default=None), read={'all'} ) #: Like pinned tweets. You can keep posting updates, #: but might want to pin an update from a week ago. is_pinned = with_roles( - sa.Column(sa.Boolean, default=False, nullable=False), read={'all'} + sa.orm.mapped_column(sa.Boolean, default=False, nullable=False), read={'all'} ) - published_by_id = sa.Column( - sa.Integer, sa.ForeignKey('user.id'), nullable=True, index=True + published_by_id: Mapped[int | None] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=True, index=True ) - published_by: Mapped[Optional[User]] = with_roles( - sa.orm.relationship( - User, - backref=sa.orm.backref('published_updates', lazy='dynamic'), + published_by: Mapped[Account | None] = with_roles( + relationship( + Account, + backref=backref('published_updates', lazy='dynamic'), foreign_keys=[published_by_id], ), read={'all'}, ) published_at = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'} + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'} ) - deleted_by_id = sa.Column( - sa.Integer, sa.ForeignKey('user.id'), nullable=True, index=True + deleted_by_id: Mapped[int | None] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=True, index=True ) - deleted_by: Mapped[Optional[User]] = with_roles( - sa.orm.relationship( - User, - backref=sa.orm.backref('deleted_updates', lazy='dynamic'), + deleted_by: Mapped[Account | None] = with_roles( + relationship( + Account, + backref=backref('deleted_updates', lazy='dynamic'), foreign_keys=[deleted_by_id], ), read={'reader'}, ) deleted_at = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True), read={'reader'} + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), + read={'reader'}, ) edited_at = with_roles( - sa.Column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'} + sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'} ) - commentset_id = sa.Column( + commentset_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id'), nullable=False ) commentset = with_roles( - sa.orm.relationship( + relationship( Commentset, uselist=False, lazy='joined', cascade='all', single_parent=True, - backref=sa.orm.backref('update', uselist=False), + backref=backref('update', uselist=False), ), read={'all'}, ) - search_vector: Mapped[TSVectorType] = sa.orm.deferred( - sa.Column( - TSVectorType( - 'name', - 'title', - 'body_text', - weights={'name': 'A', 'title': 'A', 'body_text': 'B'}, - regconfig='english', - hltext=lambda: sa.func.concat_ws( - visual_field_delimiter, Update.title, Update.body_html - ), + search_vector: Mapped[TSVectorType] = sa.orm.mapped_column( + TSVectorType( + 'name', + 'title', + 'body_text', + weights={'name': 'A', 'title': 'A', 'body_text': 'B'}, + regconfig='english', + hltext=lambda: sa.func.concat_ws( + visual_field_delimiter, Update.title, Update.body_html ), - nullable=False, - ) + ), + nullable=False, + deferred=True, ) __roles__ = { @@ -203,7 +219,7 @@ class Update( 'body_html', 'published_at', 'edited_at', - 'user', + 'created_by', 'is_pinned', 'is_restricted', 'is_currently_restricted', @@ -221,7 +237,7 @@ class Update( 'body_html', 'published_at', 'edited_at', - 'user', + 'created_by', 'is_pinned', 'is_restricted', 'is_currently_restricted', @@ -265,13 +281,13 @@ def state_label(self) -> str: 'WITHDRAWN', state.DRAFT, lambda update: update.published_at is not None, - lambda update: update.published_at.isnot(None), + lambda update: update.published_at.is_not(None), label=('withdrawn', __("Withdrawn")), ) @with_roles(call={'editor'}) @state.transition(state.DRAFT, state.PUBLISHED) - def publish(self, actor: User) -> bool: + def publish(self, actor: Account) -> bool: first_publishing = False self.published_by = actor if self.published_at is None: @@ -292,7 +308,7 @@ def undo_publish(self) -> None: @with_roles(call={'creator', 'editor'}) @state.transition(None, state.DELETED) - def delete(self, actor: User) -> None: + def delete(self, actor: Account) -> None: if self.state.UNPUBLISHED: # If it was never published, hard delete it db.session.delete(self) @@ -337,7 +353,7 @@ def is_currently_restricted(self) -> bool: with_roles(is_currently_restricted, read={'all'}) 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 not self.visibility_state.RESTRICTED: @@ -349,13 +365,13 @@ def roles_for( return roles @classmethod - def all_published_public(cls) -> Query: + def all_published_public(cls) -> Query[Update]: return cls.query.join(Project).filter( Project.state.PUBLISHED, cls.state.PUBLISHED, cls.visibility_state.PUBLIC ) @with_roles(read={'all'}) - def getnext(self) -> Optional[Update]: + def getnext(self) -> Update | None: """Get next published update.""" if self.state.PUBLISHED: return ( @@ -370,7 +386,7 @@ def getnext(self) -> Optional[Update]: return None @with_roles(read={'all'}) - def getprev(self) -> Optional[Update]: + def getprev(self) -> Update | None: """Get previous published update.""" if self.state.PUBLISHED: return ( @@ -409,7 +425,7 @@ def draft_updates(self) -> BaseQuery: with_roles(draft_updates, read={'editor'}) @property - def pinned_update(self) -> Optional[Update]: + def pinned_update(self) -> Update | None: return ( self.updates.filter(Update.state.PUBLISHED, Update.is_pinned.is_(True)) .order_by(Update.published_at.desc()) diff --git a/funnel/models/user.py b/funnel/models/user.py deleted file mode 100644 index 96e3daa64..000000000 --- a/funnel/models/user.py +++ /dev/null @@ -1,2059 +0,0 @@ -"""User, organization, team and user anchor models.""" - -from __future__ import annotations - -from datetime import timedelta -from typing import Any, Iterable, Iterator, List, Optional, Set, Union, cast, overload -from uuid import UUID -import hashlib -import itertools - -from sqlalchemy.ext.associationproxy import association_proxy - -from werkzeug.utils import cached_property - -from passlib.hash import argon2, bcrypt -from typing_extensions import Literal -import phonenumbers - -from baseframe import __ -from coaster.sqlalchemy import ( - Query, - RoleMixin, - StateManager, - add_primary_relationship, - auto_init_default, - failsafe_add, - with_roles, -) -from coaster.utils import LabeledEnum, newsecret, require_one_of, utcnow - -from ..typing import OptionalMigratedTables -from . import ( - BaseMixin, - LocaleType, - Mapped, - TimezoneType, - TSVectorType, - UuidMixin, - db, - declarative_mixin, - hybrid_property, - sa, -) -from .email_address import EmailAddress, EmailAddressMixin -from .helpers import ImgeeFurl, add_search_trigger, quote_autocomplete_like -from .phone_number import PhoneNumber, PhoneNumberMixin - -__all__ = [ - 'USER_STATE', - 'deleted_user', - 'removed_user', - 'User', - 'DuckTypeUser', - 'UserOldId', - 'Organization', - 'Team', - 'UserEmail', - 'UserEmailClaim', - 'UserPhone', - 'UserExternalId', - 'Anchor', -] - - -@declarative_mixin -class SharedProfileMixin: - """Common methods between User and Organization to link to Profile.""" - - # The `name` property in User and Organization is not over here because - # of what seems to be a SQLAlchemy bug: we can't override the expression - # (both models need separate expressions) without triggering an inspection - # of the `profile` relationship, which does not exist yet as the backrefs - # are only fully setup when module loading is finished. - # Doc: https://docs.sqlalchemy.org/en/latest/orm/extensions/hybrid.html - # #reusing-hybrid-properties-across-subclasses - - name: Optional[str] - profile: Optional[Profile] - - def validate_name_candidate(self, name: str) -> Optional[str]: - """Validate if name is valid for this object, returning an error identifier.""" - if name and self.name and name.lower() == self.name.lower(): - # Same name, or only a case change. No validation required - return None - return Profile.validate_name_candidate(name) - - @property - def has_public_profile(self) -> bool: - """Return the visibility state of an account.""" - profile = self.profile - return profile is not None and bool(profile.state.PUBLIC) - - with_roles(has_public_profile, read={'all'}, write={'owner'}) - - @property - def avatar(self) -> Optional[ImgeeFurl]: - """Return avatar image URL.""" - profile = self.profile - return ( - profile.logo_url - if profile is not None - and profile.logo_url is not None - and profile.logo_url.url != '' - else None - ) - - @property - def profile_url(self) -> Optional[str]: - """Return optional URL to account page.""" - profile = self.profile - return profile.url_for() if profile is not None else None - - with_roles(profile_url, read={'all'}) - - -class USER_STATE(LabeledEnum): # noqa: N801 - """State codes for user accounts.""" - - #: Regular, active user - ACTIVE = (1, __("Active")) - #: Suspended account (cause and explanation not included here) - SUSPENDED = (2, __("Suspended")) - #: Merged into another user - MERGED = (3, __("Merged")) - #: Invited to make an account, doesn't have one yet - INVITED = (4, __("Invited")) - #: Permanently deleted account - DELETED = (5, __("Deleted")) - - -class ORGANIZATION_STATE(LabeledEnum): # noqa: N801 - """State codes for organizations.""" - - #: Regular, active organization - ACTIVE = (1, __("Active")) - #: Suspended organization (cause and explanation not included here) - SUSPENDED = (2, __("Suspended")) - - -@declarative_mixin -class EnumerateMembershipsMixin: - """Support mixin for enumeration of memberships.""" - - __active_membership_attrs__: Set[str] - __noninvite_membership_attrs__: Set[str] - - def __init_subclass__(cls, **kwargs) -> None: - super().__init_subclass__(**kwargs) - cls.__active_membership_attrs__ = set() - cls.__noninvite_membership_attrs__ = set() - - 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 subject User or Profile is safe to purge - (hard delete) from the database. If non-invite memberships are present, the - subject cannot be purged as immutable records must be preserved. Instead, the - subject 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__ - ) - - -class User( - SharedProfileMixin, - EnumerateMembershipsMixin, - UuidMixin, - BaseMixin, - db.Model, # type: ignore[name-defined] -): - """User model.""" - - __tablename__ = 'user' - __allow_unmapped__ = True - __title_length__ = 80 - - #: The user's fullname - fullname: Mapped[str] = with_roles( - sa.Column(sa.Unicode(__title_length__), default='', nullable=False), - read={'all'}, - ) - #: Alias for the user's fullname - title: Mapped[str] = sa.orm.synonym('fullname') - #: Argon2 or Bcrypt hash of the user's password - pw_hash = sa.Column(sa.Unicode, nullable=True) - #: Timestamp for when the user's password last changed - pw_set_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) - #: Expiry date for the password (to prompt user to reset it) - pw_expires_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) - #: User's preferred/last known timezone - timezone = with_roles( - sa.Column(TimezoneType(backend='pytz'), nullable=True), read={'owner'} - ) - #: Update timezone automatically from browser activity - auto_timezone = sa.Column(sa.Boolean, default=True, nullable=False) - #: User's preferred/last known locale - locale = with_roles(sa.Column(LocaleType, nullable=True), read={'owner'}) - #: Update locale automatically from browser activity - auto_locale = sa.Column(sa.Boolean, default=True, nullable=False) - #: User's state code (active, suspended, merged, deleted) - _state = sa.Column( - 'state', - sa.SmallInteger, - StateManager.check_constraint('state', USER_STATE), - nullable=False, - default=USER_STATE.ACTIVE, - ) - #: User account state manager - state = StateManager('_state', USER_STATE, doc="User account state") - #: Other user accounts that were merged into this user account - oldusers = association_proxy('oldids', 'olduser') - - search_vector: Mapped[TSVectorType] = sa.orm.deferred( - sa.Column( - TSVectorType( - 'fullname', - weights={'fullname': 'A'}, - regconfig='english', - hltext=lambda: User.fullname, - ), - nullable=False, - ) - ) - - __table_args__ = ( - sa.Index( - 'ix_user_fullname_lower', - sa.func.lower(fullname).label('fullname_lower'), - postgresql_ops={'fullname_lower': 'varchar_pattern_ops'}, - ), - sa.Index('ix_user_search_vector', 'search_vector', postgresql_using='gin'), - ) - - __roles__ = { - 'all': { - 'read': { - 'uuid', - 'name', - 'title', - 'fullname', - 'username', - 'pickername', - 'timezone', - 'avatar', - 'created_at', - 'profile', - 'profile_url', - 'urls', - }, - 'call': {'views', 'forms', 'features', 'url_for'}, - } - } - - __datasets__ = { - 'primary': { - 'uuid', - 'name', - 'title', - 'fullname', - 'username', - 'pickername', - 'timezone', - 'avatar', - 'created_at', - 'profile', - 'profile_url', - 'urls', - }, - 'related': { - 'name', - 'title', - 'fullname', - 'username', - 'pickername', - 'timezone', - 'avatar', - 'created_at', - 'profile_url', - }, - } - - @classmethod - def _defercols(cls): - """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), - ] - - primary_email: Optional[UserEmail] - primary_phone: Optional[UserPhone] - - @hybrid_property - def name(self) -> Optional[str]: - """Return @name (username) from linked account.""" # noqa: D402 - if self.profile: - return self.profile.name - return None - - @name.setter - def name(self, value: Optional[str]): - """Set @name.""" - if value is None or not value.strip(): - if self.profile is not None: - raise ValueError("Name is required") - else: - if self.profile is not None: - self.profile.name = value - else: - self.profile = Profile(name=value, user=self, uuid=self.uuid) - db.session.add(self.profile) - - @name.expression - def name(cls): # noqa: N805 # pylint: disable=no-self-argument - """Return @name from linked account as a SQL expression.""" - return sa.select(Profile.name).where(Profile.user_id == cls.id).label('name') - - with_roles(name, read={'all'}) - username: Optional[str] = name # type: ignore[assignment] - - @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_user(self) -> User: - """Return the user account that this account was merged into (default: self).""" - if self.state.MERGED: - # If our state is MERGED, there _must_ be a corresponding UserOldId record - return cast(UserOldId, UserOldId.get(self.uuid)).user - return self - - def _set_password(self, password: Optional[str]): - """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) # type: ignore - - #: 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 __repr__(self) -> str: - """Represent :class:`User` as a string.""" - with db.session.no_autoflush: - if 'profile' in self.__dict__: - return f"" - return f"" - - def __str__(self) -> str: - """Return picker name for user.""" - return self.pickername - - @property - def pickername(self) -> str: - """Return fullname and @name in a format suitable for identification.""" - if self.username: - return f'{self.fullname} (@{self.username})' - return self.fullname - - with_roles(pickername, read={'all'}) - - def add_email( - self, - email: str, - primary: bool = False, - private: bool = False, - ) -> UserEmail: - """Add an email address (assumed to be verified).""" - useremail = UserEmail(user=self, email=email, private=private) - useremail = cast( - UserEmail, - failsafe_add( - db.session, useremail, user=self, email_address=useremail.email_address - ), - ) - if primary: - self.primary_email = useremail - return useremail - # FIXME: This should remove competing instances of UserEmailClaim - - def del_email(self, email: str) -> None: - """Remove an email address from the user's account.""" - useremail = UserEmail.get_for(user=self, email=email) - if useremail is not None: - if self.primary_email in (useremail, None): - self.primary_email = ( - UserEmail.query.filter( - UserEmail.user == self, UserEmail.id != useremail.id - ) - .order_by(UserEmail.created_at.desc()) - .first() - ) - db.session.delete(useremail) - - @property - def email(self) -> Union[Literal[''], UserEmail]: - """Return primary email address for user.""" - # Look for a primary address - useremail = self.primary_email - if useremail is not None: - return useremail - # No primary? Maybe there's one that's not set as primary? - if self.emails: - useremail = 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 = useremail - return useremail - # 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, - ) -> UserPhone: - """Add a phone number (assumed to be verified).""" - userphone = UserPhone(user=self, phone=phone, private=private) - userphone = cast( - UserPhone, - failsafe_add( - db.session, userphone, user=self, phone_number=userphone.phone_number - ), - ) - if primary: - self.primary_phone = userphone - return userphone - - def del_phone(self, phone: str) -> None: - """Remove a phone number from the user's account.""" - userphone = UserPhone.get_for(user=self, phone=phone) - if userphone is not None: - if self.primary_phone in (userphone, None): - self.primary_phone = ( - UserPhone.query.filter( - UserPhone.user == self, UserPhone.id != userphone.id - ) - .order_by(UserPhone.created_at.desc()) - .first() - ) - db.session.delete(userphone) - - @property - def phone(self) -> Union[Literal[''], UserPhone]: - """Return primary phone number for user.""" - # Look for a primary phone number - userphone = self.primary_phone - if userphone is not None: - return userphone - # No primary? Maybe there's one that's not set as primary? - if self.phones: - userphone = 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 = userphone - return userphone - # 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'}) - - def is_profile_complete(self) -> bool: - """Verify if profile is complete (fullname, username and contacts present).""" - return bool(self.fullname and self.username and self.has_verified_contact_info) - - # --- 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) -> Optional[UserEmail]: - """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) -> Optional[UserPhone]: - """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): # 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): # 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): - """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): - """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: Any # type: ignore[name-defined] - ) -> Optional[Union[UserEmail, UserPhone]]: - """ - 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=None) -> Optional[Union[UserEmail, UserEmailClaim]]: - """ - Return default email address (verified if present, else unverified). - - ..note:: - This is a temporary helper method, pending merger of :class:`UserEmailClaim` - into :class:`UserEmail` with :attr:`~UserEmail.verified` ``== False``. The - appropriate replacement is :meth:`User.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) -> User: - """ - 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.organization_id - for membership in self.active_organization_owner_memberships - ] - - @state.transition(state.ACTIVE, state.MERGED) - def mark_merged_into(self, other_user): - """Mark account as merged into another account.""" - db.session.add(UserOldId(id=self.uuid, user=other_user)) - - @state.transition(state.ACTIVE, state.SUSPENDED) - def mark_suspended(self): - """Mark account as suspended on support or moderator request.""" - - @state.transition(state.ACTIVE, state.DELETED) - def do_delete(self): - """Delete user account.""" - # 0: Safety check - if self.profile and not self.profile.is_safe_to_delete(): - raise ValueError("Profile 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_subject_attribution(self) - if membership.revoke_on_subject_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_subject_delete - ): - self.active_site_membership.revoke(actor=self) - - # 3. Drop all team memberships - self.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 user_session in self.active_user_sessions: - user_session.revoke() - - # 6. Delete account (nee profile) and release username, unless it is implicated - # in membership records (including revoked records). - if ( - self.profile - and self.profile.do_delete(self) # This call removes data and confirms it - and self.profile.is_safe_to_purge() - ): - db.session.delete(self.profile) - - # 6. Clear fullname and stored password hash - self.fullname = '' - self.password = None - - @overload - @classmethod - def get( - cls, - *, - username: str, - defercols: bool = False, - ) -> Optional[User]: - ... - - @overload - @classmethod - def get( - cls, - *, - buid: str, - defercols: bool = False, - ) -> Optional[User]: - ... - - @overload - @classmethod - def get( - cls, - *, - userid: str, - defercols: bool = False, - ) -> Optional[User]: - ... - - @classmethod - def get( - cls, - *, - username: Optional[str] = None, - buid: Optional[str] = None, - userid: Optional[str] = None, - defercols: bool = False, - ) -> Optional[User]: - """ - Return a User with the given username or buid. - - :param str username: Username to lookup - :param str buid: Buid to lookup - :param bool defercols: Defer loading non-critical columns - """ - require_one_of(username=username, buid=buid, userid=userid) - - # userid parameter is temporary for Flask-Lastuser compatibility - if userid: - buid = userid - - if username is not None: - query = ( - cls.query.join(Profile) - .filter(sa.func.lower(Profile.name) == sa.func.lower(username)) - .options(sa.orm.joinedload(cls.profile)) - ) - else: - query = cls.query.filter_by(buid=buid).options( - sa.orm.joinedload(cls.profile) - ) - if defercols: - query = query.options(*cls._defercols()) - user = query.one_or_none() - if user and user.state.MERGED: - user = user.merged_user() - if user and user.state.ACTIVE: - return user - return None - - @classmethod - def all( # noqa: A003 - cls, - buids: Optional[Iterable[str]] = None, - usernames: Optional[Iterable[str]] = None, - defercols: bool = False, - ) -> List[User]: - """ - Return all matching users. - - :param list buids: Buids to look up - :param list usernames: Usernames to look up - :param bool defercols: Defer loading non-critical columns - """ - users = set() - if buids and usernames: - # Use .outerjoin(Profile) or users without usernames will be excluded - query = cls.query.outerjoin(Profile).filter( - sa.or_( - cls.buid.in_(buids), # type: ignore[attr-defined] - sa.func.lower(Profile.name).in_( - [username.lower() for username in usernames] - ), - ) - ) - elif buids: - query = cls.query.filter(cls.buid.in_(buids)) # type: ignore[attr-defined] - elif usernames: - query = cls.query.join(Profile).filter( - sa.func.lower(Profile.name).in_( - [username.lower() for username in usernames] - ) - ) - else: - raise TypeError("A parameter is required") - - if defercols: - query = query.options(*cls._defercols()) - for user in query.all(): - user = user.merged_user() - if user.state.ACTIVE: - users.add(user) - return list(users) - - @classmethod - def autocomplete(cls, prefix: str) -> List[User]: - """ - Return users whose names begin with the query, for autocomplete widgets. - - Looks up users by fullname, username, external ids and email addresses. - - :param str query: Letters to start matching with - """ - # Escape the '%' and '_' wildcards in SQL LIKE clauses. - # Some SQL dialects respond to '[' and ']', so remove them. - like_query = quote_autocomplete_like(prefix) - - # We convert to lowercase and use the LIKE operator since ILIKE isn't standard - # and doesn't use an index in PostgreSQL. There's a functional index for lower() - # defined above in __table_args__ that also applies to LIKE lower(val) queries. - - if like_query in ('%', '@%'): - return [] - - # base_users is used in two of the three possible queries below - base_users = ( - # Use outerjoin(Profile) to find users without profiles (not inner join) - cls.query.outerjoin(Profile) - .filter( - cls.state.ACTIVE, - sa.or_( - sa.func.lower(cls.fullname).like(sa.func.lower(like_query)), - sa.func.lower(Profile.name).like(sa.func.lower(like_query)), - ), - ) - .options(*cls._defercols()) - .order_by(User.fullname) - .limit(20) - ) - - if ( - prefix != '@' - and prefix.startswith('@') - and UserExternalId.__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 -> User.username - cls.query.join(Profile) - .filter( - cls.state.ACTIVE, - sa.func.lower(Profile.name).like(sa.func.lower(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 -> User.fullname - # cls.query.filter( - # cls.state.ACTIVE, - # sa.func.lower(cls.fullname).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 - users = ( - cls.query.join(UserEmail) - .join(EmailAddress) - .filter( - EmailAddress.get_filter(email=prefix), - cls.state.ACTIVE, - ) - .options(*cls._defercols()) - .limit(20) - # .union(base_users) # FIXME: Broken in SQLAlchemy 1.4.17 - .all() - ) - else: - # No '@' in the query, so do a regular autocomplete - users = base_users.all() - return users - - @classmethod - def active_user_count(cls) -> int: - """Count of all active user 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 [] - - -# XXX: Deprecated, still here for Baseframe compatibility -User.userid = User.uuid_b64 - - -auto_init_default(User._state) # pylint: disable=protected-access -add_search_trigger(User, 'search_vector') - - -class UserOldId(UuidMixin, BaseMixin, db.Model): # type: ignore[name-defined] - """Record of an older UUID for a user, after account merger.""" - - __tablename__ = 'user_oldid' - __allow_unmapped__ = True - __uuid_primary_key__ = True - - #: Old user account, if still present - olduser: Mapped[User] = sa.orm.relationship( - User, - primaryjoin='foreign(UserOldId.id) == remote(User.uuid)', - backref=sa.orm.backref('oldid', uselist=False), - ) - #: User id of new user - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - #: New user account - user: Mapped[User] = sa.orm.relationship( - User, foreign_keys=[user_id], backref=sa.orm.backref('oldids', cascade='all') - ) - - def __repr__(self) -> str: - """Represent :class:`UserOldId` as a string.""" - return f'' - - @classmethod - def get(cls, uuid: UUID) -> Optional[UserOldId]: - """Get an old user record given a UUID.""" - return cls.query.filter_by(id=uuid).one_or_none() - - -class DuckTypeUser(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: None = None - profile_url: None = None - email: None = None - phone: None = None - - # Copy registries from User model - views = User.views - features = User.features - forms = User.forms - - __roles__ = { - 'all': { - 'read': { - 'id', - 'uuid', - 'username', - 'fullname', - 'pickername', - 'profile', - 'profile_url', - }, - 'call': {'views', 'forms', 'features', 'url_for'}, - } - } - - __datasets__ = { - 'related': { - 'username', - 'fullname', - 'pickername', - 'profile', - 'profile_url', - } - } - - #: Make obj.user 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: - """Represent user account as a string.""" - return self.pickername - - def url_for(self, *args, **kwargs) -> Literal['']: - """Return blank URL for anything to do with this user.""" - return '' - - -deleted_user = DuckTypeUser(__("[deleted]")) -removed_user = DuckTypeUser(__("[removed]")) - - -# --- Organizations and teams ------------------------------------------------- - -team_membership = sa.Table( - 'team_membership', - db.Model.metadata, # type: ignore[has-type] - sa.Column( - 'user_id', - sa.Integer, - sa.ForeignKey('user.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( - SharedProfileMixin, - EnumerateMembershipsMixin, - UuidMixin, - BaseMixin, - db.Model, # type: ignore[name-defined] -): - """An organization of one or more users with distinct roles.""" - - __tablename__ = 'organization' - __allow_unmapped__ = True - __title_length__ = 80 - - # profile: Mapped[Profile] - - title = with_roles( - sa.Column(sa.Unicode(__title_length__), default='', nullable=False), - read={'all'}, - ) - - #: Organization's state (active, suspended) - _state = sa.Column( - 'state', - sa.SmallInteger, - StateManager.check_constraint('state', ORGANIZATION_STATE), - nullable=False, - default=ORGANIZATION_STATE.ACTIVE, - ) - #: Organization state manager - state = StateManager('_state', ORGANIZATION_STATE, doc="Organization state") - - search_vector: Mapped[TSVectorType] = sa.orm.deferred( - sa.Column( - TSVectorType( - 'title', - weights={'title': 'A'}, - regconfig='english', - hltext=lambda: Organization.title, - ), - nullable=False, - ) - ) - - __table_args__ = ( - sa.Index( - 'ix_organization_search_vector', 'search_vector', postgresql_using='gin' - ), - ) - - __roles__ = { - 'all': { - 'read': { - 'name', - 'title', - 'pickername', - 'created_at', - 'profile', - 'profile_url', - 'urls', - }, - 'call': {'views', 'features', 'forms', 'url_for'}, - } - } - - __datasets__ = { - 'primary': { - 'name', - 'title', - 'username', - 'pickername', - 'avatar', - 'created_at', - 'profile', - 'profile_url', - }, - 'related': {'name', 'title', 'pickername', 'created_at'}, - } - - @classmethod - def _defercols(cls): - """Return columns that are usually deferred from loading.""" - defer = sa.orm.defer - return [ - defer(cls.created_at), - defer(cls.updated_at), - ] - - def __init__(self, owner: User, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - db.session.add( - OrganizationMembership( - organization=self, user=owner, granted_by=owner, is_owner=True - ) - ) - - @hybrid_property - def name(self) -> str: - """Return username from linked account.""" - return self.profile.name - - @name.setter - def name(self, value: Optional[str]) -> None: - """Set a new @name for the organization.""" - if value is None or not value.strip(): - raise ValueError("Name is required") - if self.profile is not None: - self.profile.name = value - else: - # This code will only be reachable during `__init__` - self.profile = Profile( # type: ignore[unreachable] - name=value, organization=self, uuid=self.uuid - ) - db.session.add(self.profile) - - @name.expression - def name( # pylint: disable=no-self-argument - cls, # noqa: N805 - ) -> sa.Select: - """Return @name from linked profile as a SQL expression.""" - return ( # type: ignore[return-value] - sa.select(Profile.name) - .where(Profile.organization_id == cls.id) - .label('name'), - ) - - with_roles(name, read={'all'}) - - def __repr__(self) -> str: - """Represent :class:`Organization` as a string.""" - with db.session.no_autoflush: - if 'profile' in self.__dict__: - return f"" - return f"" - - @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 people(self) -> Query: - """Return a list of users from across the public teams they are in.""" - return ( - User.query.join(team_membership) - .join(Team) - .filter(Team.organization == self, Team.is_public.is_(True)) - .options(sa.orm.joinedload(User.teams)) - .order_by(sa.func.lower(User.fullname)) - ) - - @state.transition(state.ACTIVE, state.SUSPENDED) - def mark_suspended(self): - """Mark organization as suspended on support request.""" - - @state.transition(state.SUSPENDED, state.ACTIVE) - def mark_active(self): - """Mark organization as active on support request.""" - - @overload - @classmethod - def get( - cls, - *, - name: str, - defercols: bool = False, - ) -> Optional[Organization]: - ... - - @overload - @classmethod - def get( - cls, - *, - buid: str, - defercols: bool = False, - ) -> Optional[Organization]: - ... - - @classmethod - def get( - cls, - *, - name: Optional[str] = None, - buid: Optional[str] = None, - defercols: bool = False, - ) -> Optional[Organization]: - """ - Return an Organization with matching name or buid. - - Note that ``name`` is the username, not the title. - - :param str name: Name of the organization - :param str buid: Buid of the organization - :param bool defercols: Defer loading non-critical columns - """ - require_one_of(name=name, buid=buid) - - if name is not None: - query = ( - cls.query.join(Profile) - .filter(sa.func.lower(Profile.name) == sa.func.lower(name)) - .options(sa.orm.joinedload(cls.profile)) - ) - else: - query = cls.query.filter_by(buid=buid).options( - sa.orm.joinedload(cls.profile) - ) - if defercols: - query = query.options(*cls._defercols()) - return query.one_or_none() - - @classmethod - def all( # noqa: A003 - cls, - buids: Optional[Iterable[str]] = None, - names: Optional[Iterable[str]] = None, - defercols: bool = False, - ) -> List[Organization]: - """Get all organizations with matching `buids` and `names`.""" - orgs = [] - if buids: - query = cls.query.filter(cls.buid.in_(buids)) # type: ignore[attr-defined] - if defercols: - query = query.options(*cls._defercols()) - orgs.extend(query.all()) - if names: - query = cls.query.join(Profile).filter( - sa.func.lower(Profile.name).in_([name.lower() for name in names]) - ) - if defercols: - query = query.options(*cls._defercols()) - orgs.extend(query.all()) - return orgs - - -add_search_trigger(Organization, 'search_vector') - - -class Team(UuidMixin, BaseMixin, db.Model): # type: ignore[name-defined] - """A team of users within an organization.""" - - __tablename__ = 'team' - __allow_unmapped__ = True - __title_length__ = 250 - #: Displayed name - title = sa.Column(sa.Unicode(__title_length__), nullable=False) - #: Organization - organization_id = sa.Column( - sa.Integer, sa.ForeignKey('organization.id'), nullable=False - ) - organization = with_roles( - sa.orm.relationship( - Organization, - backref=sa.orm.backref( - 'teams', order_by=sa.func.lower(title), cascade='all' - ), - ), - grants_via={None: {'owner': 'owner', 'admin': 'admin'}}, - ) - users = with_roles( - sa.orm.relationship( - User, secondary=team_membership, lazy='dynamic', backref='teams' - ), - grants={'subject'}, - ) - - is_public = sa.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_user(cls, old_user: User, new_user: User) -> Optional[Iterable[str]]: - """Migrate one user account to another when merging user accounts.""" - for team in list(old_user.teams): - if team not in new_user.teams: - # FIXME: This creates new memberships, updating `created_at`. - # Unfortunately, we can't work with model instances as in the other - # `migrate_user` methods as team_membership is an unmapped table. - new_user.teams.append(team) - old_user.teams.remove(team) - return [cls.__table__.name, team_membership.name] - - @classmethod - def get(cls, buid: str, with_parent: bool = False) -> Optional[Team]: - """ - Return a Team with matching buid. - - :param str buid: Buid of the team - """ - if with_parent: - query = cls.query.options(sa.orm.joinedload(cls.organization)) - else: - query = cls.query - return query.filter_by(buid=buid).one_or_none() - - -# --- User email/phone and misc - - -class UserEmail(EmailAddressMixin, BaseMixin, db.Model): # type: ignore[name-defined] - """An email address linked to a user account.""" - - __tablename__ = 'user_email' - __allow_unmapped__ = True - __email_optional__ = False - __email_unique__ = True - __email_is_exclusive__ = True - __email_for__ = 'user' - - # Tell mypy that these are not optional - email_address: Mapped[EmailAddress] - - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = sa.orm.relationship( - User, backref=sa.orm.backref('emails', cascade='all') - ) - - private = sa.Column(sa.Boolean, nullable=False, default=False) - - __datasets__ = { - 'primary': {'user', 'email', 'private', 'type'}, - 'without_parent': {'email', 'private', 'type'}, - 'related': {'email', 'private', 'type'}, - } - - def __init__(self, user: User, **kwargs) -> None: - email = kwargs.pop('email', None) - if email: - kwargs['email_address'] = EmailAddress.add_for(user, email) - super().__init__(user=user, **kwargs) - - def __repr__(self) -> str: - """Represent :class:`UserEmail` 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.user.primary_email == self - - @primary.setter - def primary(self, value: bool) -> None: - """Set or unset this email address as primary.""" - if value: - self.user.primary_email = self - else: - if self.user.primary_email == self: - self.user.primary_email = None - - @overload - @classmethod - def get( - cls, - email: str, - ) -> Optional[UserEmail]: - ... - - @overload - @classmethod - def get( - cls, - *, - blake2b160: bytes, - ) -> Optional[UserEmail]: - ... - - @overload - @classmethod - def get( - cls, - *, - email_hash: str, - ) -> Optional[UserEmail]: - ... - - @classmethod - def get( - cls, - email: Optional[str] = None, - *, - blake2b160: Optional[bytes] = None, - email_hash: Optional[str] = None, - ) -> Optional[UserEmail]: - """ - Return a UserEmail 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 - """ - return ( - cls.query.join(EmailAddress) - .filter( - EmailAddress.get_filter( - email=email, blake2b160=blake2b160, email_hash=email_hash - ) - ) - .one_or_none() - ) - - @overload - @classmethod - def get_for( - cls, - user: User, - *, - email: str, - ) -> Optional[UserEmail]: - ... - - @overload - @classmethod - def get_for( - cls, - user: User, - *, - blake2b160: bytes, - ) -> Optional[UserEmail]: - ... - - @overload - @classmethod - def get_for( - cls, - user: User, - *, - email_hash: str, - ) -> Optional[UserEmail]: - ... - - @classmethod - def get_for( - cls, - user: User, - *, - email: Optional[str] = None, - blake2b160: Optional[bytes] = None, - email_hash: Optional[str] = None, - ) -> Optional[UserEmail]: - """ - Return a UserEmail with matching email or hash if it belongs to the given user. - - :param User user: User 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 - """ - return ( - cls.query.join(EmailAddress) - .filter( - cls.user == user, - EmailAddress.get_filter( - email=email, blake2b160=blake2b160, email_hash=email_hash - ), - ) - .one_or_none() - ) - - @classmethod - def migrate_user(cls, old_user: User, new_user: User) -> OptionalMigratedTables: - """Migrate one user account to another when merging user accounts.""" - primary_email = old_user.primary_email - for useremail in list(old_user.emails): - useremail.user = new_user - if new_user.primary_email is None: - new_user.primary_email = primary_email - old_user.primary_email = None - return [cls.__table__.name, user_email_primary_table.name] - - -class UserEmailClaim( - EmailAddressMixin, - BaseMixin, - db.Model, # type: ignore[name-defined] -): - """Claimed but unverified email address for a user.""" - - __tablename__ = 'user_email_claim' - __allow_unmapped__ = True - __email_optional__ = False - __email_unique__ = False - __email_for__ = 'user' - __email_is_exclusive__ = False - - # Tell mypy that these are not optional - email_address: Mapped[EmailAddress] - - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = sa.orm.relationship( - User, backref=sa.orm.backref('emailclaims', cascade='all') - ) - verification_code = sa.Column(sa.String(44), nullable=False, default=newsecret) - - private = sa.Column(sa.Boolean, nullable=False, default=False) - - __table_args__ = (sa.UniqueConstraint('user_id', 'email_address_id'),) - - __datasets__ = { - 'primary': {'user', 'email', 'private', 'type'}, - 'without_parent': {'email', 'private', 'type'}, - 'related': {'email', 'private', 'type'}, - } - - def __init__(self, user: User, **kwargs) -> None: - email = kwargs.pop('email', None) - if email: - kwargs['email_address'] = EmailAddress.add_for(user, email) - super().__init__(user=user, **kwargs) - self.blake2b = hashlib.blake2b( - self.email.lower().encode(), digest_size=16 - ).digest() - - def __repr__(self) -> str: - """Represent :class:`UserEmailClaim` as a string.""" - return f'' - - def __str__(self): # pylint: disable=invalid-str-returned - """Return email as a string.""" - return self.email - - @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.""" - emails = {claim.email for claim in new_user.emailclaims} - for claim in list(old_user.emailclaims): - if claim.email not in emails: - claim.user = new_user - else: - # New user also made the same claim. Delete old user's claim - db.session.delete(claim) - - @overload - @classmethod - def get_for( - cls, - user: User, - *, - email: str, - ) -> Optional[UserEmailClaim]: - ... - - @overload - @classmethod - def get_for( - cls, - user: User, - *, - blake2b160: bytes, - ) -> Optional[UserEmailClaim]: - ... - - @overload - @classmethod - def get_for( - cls, - user: User, - *, - email_hash: str, - ) -> Optional[UserEmailClaim]: - ... - - @classmethod - def get_for( - cls, - user: User, - *, - email: Optional[str] = None, - blake2b160: Optional[bytes] = None, - email_hash: Optional[str] = None, - ) -> Optional[UserEmailClaim]: - """ - Return a UserEmailClaim with matching email address for the given user. - - :param User user: User who claimed this email address - :param str email: Email address to look up - :param bytes blake2b160: 160-bit blake2b of email address to look up - :param str email_hash: Base58 rendering of 160-bit blake2b hash - """ - return ( - cls.query.join(EmailAddress) - .filter( - cls.user == user, - EmailAddress.get_filter( - email=email, blake2b160=blake2b160, email_hash=email_hash - ), - ) - .one_or_none() - ) - - @overload - @classmethod - def get_by( - cls, - verification_code: str, - *, - email: str, - ) -> Optional[UserEmailClaim]: - ... - - @overload - @classmethod - def get_by( - cls, - verification_code: str, - *, - blake2b160: bytes, - ) -> Optional[UserEmailClaim]: - ... - - @overload - @classmethod - def get_by( - cls, - verification_code: str, - *, - email_hash: str, - ) -> Optional[UserEmailClaim]: - ... - - @classmethod - def get_by( - cls, - verification_code: str, - *, - email: Optional[str] = None, - blake2b160: Optional[bytes] = None, - email_hash: Optional[str] = None, - ) -> Optional[UserEmailClaim]: - """Return UserEmailClaim instance given verification code and email or hash.""" - return ( - cls.query.join(EmailAddress) - .filter( - cls.verification_code == verification_code, - EmailAddress.get_filter( - email=email, blake2b160=blake2b160, email_hash=email_hash - ), - ) - .one_or_none() - ) - - @classmethod - def all(cls, email: str) -> Query: # noqa: A003 - """ - Return all UserEmailClaim instances with matching email address. - - :param str email: Email address to lookup - """ - return cls.query.join(EmailAddress).filter(EmailAddress.get_filter(email=email)) - - -auto_init_default(UserEmailClaim.verification_code) - - -class UserPhone(PhoneNumberMixin, BaseMixin, db.Model): # type: ignore[name-defined] - """A phone number linked to a user account.""" - - __tablename__ = 'user_phone' - __allow_unmapped__ = True - __phone_optional__ = False - __phone_unique__ = True - __phone_is_exclusive__ = True - __phone_for__ = 'user' - - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = sa.orm.relationship( - User, backref=sa.orm.backref('phones', cascade='all') - ) - - private = sa.Column(sa.Boolean, nullable=False, default=False) - - __datasets__ = { - 'primary': {'user', 'phone', 'private', 'type'}, - 'without_parent': {'phone', 'private', 'type'}, - 'related': {'phone', 'private', 'type'}, - } - - def __init__(self, user, **kwargs): - phone = kwargs.pop('phone', None) - if phone: - kwargs['phone_number'] = PhoneNumber.add_for(user, phone) - super().__init__(user=user, **kwargs) - - def __repr__(self) -> str: - """Represent :class:`UserPhone` as a string.""" - return f'UserPhone(phone={self.phone!r}, user={self.user!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) -> Optional[str]: - return self.phone_number.number - - @property - def primary(self) -> bool: - """Check if this is the user's primary phone number.""" - return self.user.primary_phone == self - - @primary.setter - def primary(self, value: bool) -> None: - if value: - self.user.primary_phone = self - else: - if self.user.primary_phone == self: - self.user.primary_phone = None - - @overload - @classmethod - def get( - cls, - phone: str, - ) -> Optional[UserPhone]: - ... - - @overload - @classmethod - def get( - cls, - *, - blake2b160: bytes, - ) -> Optional[UserPhone]: - ... - - @overload - @classmethod - def get( - cls, - *, - phone_hash: str, - ) -> Optional[UserPhone]: - ... - - @classmethod - def get( - cls, - phone: Optional[str] = None, - *, - blake2b160: Optional[bytes] = None, - phone_hash: Optional[str] = None, - ) -> Optional[UserPhone]: - """ - Return a UserPhone 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, - user: User, - *, - phone: str, - ) -> Optional[UserPhone]: - ... - - @overload - @classmethod - def get_for( - cls, - user: User, - *, - blake2b160: bytes, - ) -> Optional[UserPhone]: - ... - - @overload - @classmethod - def get_for( - cls, - user: User, - *, - phone_hash: str, - ) -> Optional[UserPhone]: - ... - - @classmethod - def get_for( - cls, - user: User, - *, - phone: Optional[str] = None, - blake2b160: Optional[bytes] = None, - phone_hash: Optional[str] = None, - ) -> Optional[UserPhone]: - """ - Return a UserPhone with matching phone or hash if it belongs to the given user. - - :param User user: User 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.user == user, - PhoneNumber.get_filter( - phone=phone, blake2b160=blake2b160, phone_hash=phone_hash - ), - ) - .one_or_none() - ) - - @classmethod - def migrate_user(cls, old_user: User, new_user: User) -> OptionalMigratedTables: - """Migrate one user account to another when merging user accounts.""" - primary_phone = old_user.primary_phone - for userphone in list(old_user.phones): - userphone.user = new_user - if new_user.primary_phone is None: - new_user.primary_phone = primary_phone - old_user.primary_phone = None - return [cls.__table__.name, user_phone_primary_table.name] - - -class UserExternalId(BaseMixin, db.Model): # type: ignore[name-defined] - """An external connected account for a user.""" - - __tablename__ = 'user_externalid' - __allow_unmapped__ = True - __at_username_services__: List[str] = [] - #: Foreign key to user table - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - #: User that this connected account belongs to - user: Mapped[User] = sa.orm.relationship( - User, backref=sa.orm.backref('externalids', cascade='all') - ) - #: Identity of the external service (in app's login provider registry) - service = sa.Column(sa.UnicodeText, nullable=False) - #: Unique user id as per external service, used for identifying related accounts - userid = sa.Column(sa.UnicodeText, nullable=False) # Unique id (or obsolete OpenID) - #: Optional public-facing username on the external service - username = sa.Column(sa.UnicodeText, nullable=True) # LinkedIn once used full URLs - #: OAuth or OAuth2 access token - oauth_token = sa.Column(sa.UnicodeText, nullable=True) - #: Optional token secret (not used in OAuth2, used by Twitter with OAuth1a) - oauth_token_secret = sa.Column(sa.UnicodeText, nullable=True) - #: OAuth token type (typically 'bearer') - oauth_token_type = sa.Column(sa.UnicodeText, nullable=True) - #: OAuth2 refresh token - oauth_refresh_token = sa.Column(sa.UnicodeText, nullable=True) - #: OAuth2 token expiry in seconds, as sent by service provider - oauth_expires_in = sa.Column(sa.Integer, nullable=True) - #: OAuth2 token expiry timestamp, estimate from created_at + oauth_expires_in - oauth_expires_at = sa.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 = sa.Column( - sa.TIMESTAMP(timezone=True), default=sa.func.utcnow(), nullable=False - ) - - __table_args__ = ( - sa.UniqueConstraint('service', 'userid'), - sa.Index( - 'ix_user_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, - ) -> Optional[UserExternalId]: - ... - - @overload - @classmethod - def get( - cls, - service: str, - *, - username: str, - ) -> Optional[UserExternalId]: - ... - - @classmethod - def get( - cls, - service: str, - *, - userid: Optional[str] = None, - username: Optional[str] = None, - ) -> Optional[UserExternalId]: - """ - 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( - User, 'primary_email', UserEmail, 'user', 'user_id' -) -user_phone_primary_table = add_primary_relationship( - User, 'primary_phone', UserPhone, 'user', 'user_id' -) - -#: Anchor type -Anchor = Union[UserEmail, UserEmailClaim, UserPhone, EmailAddress] - -# Tail imports -# pylint: disable=wrong-import-position -from .membership_mixin import ImmutableMembershipMixin # isort: skip -from .organization_membership import OrganizationMembership # isort:skip -from .profile import Profile # isort:skip diff --git a/funnel/models/user_session.py b/funnel/models/user_session.py deleted file mode 100644 index abb60c4cf..000000000 --- a/funnel/models/user_session.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Model for a user's auth (login) session.""" - -from __future__ import annotations - -from datetime import timedelta -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - -from coaster.utils import utcnow - -from ..signals import session_revoked -from . import BaseMixin, Mapped, UuidMixin, db, sa -from .helpers import reopen -from .user import User - -__all__ = [ - 'UserSession', - 'UserSessionError', - 'UserSessionExpiredError', - 'UserSessionRevokedError', - 'UserSessionInactiveUserError', - 'auth_client_user_session', - 'USER_SESSION_VALIDITY_PERIOD', -] - - -class UserSessionError(Exception): - """Base exception for user session errors.""" - - -class UserSessionExpiredError(UserSessionError): - """This user session has expired and cannot be marked as currently active.""" - - -class UserSessionRevokedError(UserSessionError): - """This user session has been revoked and cannot be marked as currently active.""" - - -class UserSessionInactiveUserError(UserSessionError): - """This user is not in ACTIVE state and cannot have a currently active session.""" - - -USER_SESSION_VALIDITY_PERIOD = timedelta(days=365) - -#: When a user logs into an client app, the user's session is logged against -#: the client app in this table -auth_client_user_session: sa.Table = sa.Table( - 'auth_client_user_session', - db.Model.metadata, # type: ignore[has-type] - sa.Column( - 'auth_client_id', - sa.Integer, - sa.ForeignKey('auth_client.id'), - nullable=False, - primary_key=True, - ), - sa.Column( - 'user_session_id', - sa.Integer, - sa.ForeignKey('user_session.id'), - nullable=False, - primary_key=True, - ), - sa.Column( - 'created_at', - sa.TIMESTAMP(timezone=True), - nullable=False, - default=sa.func.utcnow(), - ), - sa.Column( - 'accessed_at', - sa.TIMESTAMP(timezone=True), - nullable=False, - default=sa.func.utcnow(), - ), -) - - -class UserSession(UuidMixin, BaseMixin, db.Model): # type: ignore[name-defined] - __tablename__ = 'user_session' - __allow_unmapped__ = True - - user_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = sa.orm.relationship( - User, backref=sa.orm.backref('all_user_sessions', cascade='all', lazy='dynamic') - ) - - #: User's last known IP address - ipaddr = sa.Column(sa.String(45), nullable=False) - #: City geonameid from IP address - geonameid_city = sa.Column(sa.Integer, nullable=True) - #: State/subdivision geonameid from IP address - geonameid_subdivision = sa.Column(sa.Integer, nullable=True) - #: Country geonameid from IP address - geonameid_country = sa.Column(sa.Integer, nullable=True) - #: User's network, from IP address - geoip_asn = sa.Column(sa.Integer, nullable=True) - #: User agent - user_agent = sa.Column(sa.UnicodeText, nullable=False) - #: The login service that was used to make this session - login_service = sa.Column(sa.Unicode, nullable=True) - - accessed_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=False) - revoked_at = sa.Column(sa.TIMESTAMP(timezone=True), nullable=True) - sudo_enabled_at = sa.Column( - sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() - ) - - def __repr__(self) -> str: - """Represent :class:`UserSession` as a string.""" - return f'' - - @property - def has_sudo(self): - return ( - self.sudo_enabled_at is None # New session, not yet written to db - or self.sudo_enabled_at > utcnow() - timedelta(minutes=15) - ) - - def set_sudo(self): - self.sudo_enabled_at = sa.func.utcnow() - - def revoke(self): - if not self.revoked_at: - self.revoked_at = sa.func.utcnow() - self.authtokens.delete(synchronize_session='fetch') - session_revoked.send(self) - - @classmethod - def get(cls, buid): - return cls.query.filter_by(buid=buid).one_or_none() - - @classmethod - def authenticate(cls, buid, silent=False): - """ - Retrieve a user session that is supposed to be active. - - If a session is invalid, exceptions will be raised to indicate the problem, - unless silent mode is enabled. - """ - if silent: - return ( - cls.query.join(User) - .filter( - # Session key must match. - cls.buid == buid, - # Sessions are valid for one year... - cls.accessed_at > sa.func.utcnow() - USER_SESSION_VALIDITY_PERIOD, - # ...unless explicitly revoked (or user logged out). - cls.revoked_at.is_(None), - # User account must be active - User.state.ACTIVE, - ) - .one_or_none() - ) - - # Not silent? Raise exceptions on expired and revoked sessions - user_session = cls.query.join(User).filter(cls.buid == buid).one_or_none() - if user_session is not None: - if user_session.accessed_at <= utcnow() - USER_SESSION_VALIDITY_PERIOD: - raise UserSessionExpiredError(user_session) - if user_session.revoked_at is not None: - raise UserSessionRevokedError(user_session) - if not user_session.user.state.ACTIVE: - raise UserSessionInactiveUserError(user_session) - return user_session - - -@reopen(User) -class __User: - active_user_sessions = sa.orm.relationship( - UserSession, - lazy='dynamic', - primaryjoin=sa.and_( - UserSession.user_id == User.id, - UserSession.accessed_at > sa.func.utcnow() - USER_SESSION_VALIDITY_PERIOD, - UserSession.revoked_at.is_(None), - ), - order_by=UserSession.accessed_at.desc(), - viewonly=True, - ) diff --git a/funnel/models/user_signals.py b/funnel/models/user_signals.py index db0eb4a87..ef4d43e74 100644 --- a/funnel/models/user_signals.py +++ b/funnel/models/user_signals.py @@ -5,6 +5,15 @@ from sqlalchemy import event from ..signals import ( + model_accountemail_deleted, + model_accountemail_edited, + model_accountemail_new, + model_accountemailclaim_deleted, + model_accountemailclaim_edited, + model_accountemailclaim_new, + model_accountphone_deleted, + model_accountphone_edited, + model_accountphone_new, model_org_deleted, model_org_edited, model_org_new, @@ -14,30 +23,28 @@ model_user_deleted, model_user_edited, model_user_new, - model_useremail_deleted, - model_useremail_edited, - model_useremail_new, - model_useremailclaim_deleted, - model_useremailclaim_edited, - model_useremailclaim_new, - model_userphone_deleted, - model_userphone_edited, - model_userphone_new, ) -from .user import Organization, Team, User, UserEmail, UserEmailClaim, UserPhone +from .account import ( + Account, + AccountEmail, + AccountEmailClaim, + AccountPhone, + Organization, + Team, +) -@event.listens_for(User, 'after_insert') +@event.listens_for(Account, 'after_insert') def _user_new(_mapper, _connection, target): model_user_new.send(target) -@event.listens_for(User, 'after_update') +@event.listens_for(Account, 'after_update') def _user_edited(_mapper, _connection, target): model_user_edited.send(target) -@event.listens_for(User, 'after_delete') +@event.listens_for(Account, 'after_delete') def _user_deleted(_mapper, _connection, target): model_user_deleted.send(target) @@ -72,46 +79,46 @@ def _team_deleted(_mapper, _connection, target): model_team_deleted.send(target) -@event.listens_for(UserEmail, 'after_insert') -def _useremail_new(_mapper, _connection, target): - model_useremail_new.send(target) +@event.listens_for(AccountEmail, 'after_insert') +def _accountemail_new(_mapper, _connection, target): + model_accountemail_new.send(target) -@event.listens_for(UserEmail, 'after_update') -def _useremail_edited(_mapper, _connection, target): - model_useremail_edited.send(target) +@event.listens_for(AccountEmail, 'after_update') +def _accountemail_edited(_mapper, _connection, target): + model_accountemail_edited.send(target) -@event.listens_for(UserEmail, 'after_delete') -def _useremail_deleted(_mapper, _connection, target): - model_useremail_deleted.send(target) +@event.listens_for(AccountEmail, 'after_delete') +def _accountemail_deleted(_mapper, _connection, target): + model_accountemail_deleted.send(target) -@event.listens_for(UserEmailClaim, 'after_insert') -def _useremailclaim_new(_mapper, _connection, target): - model_useremailclaim_new.send(target) +@event.listens_for(AccountEmailClaim, 'after_insert') +def _accountemailclaim_new(_mapper, _connection, target): + model_accountemailclaim_new.send(target) -@event.listens_for(UserEmailClaim, 'after_update') -def _useremailclaim_edited(_mapper, _connection, target): - model_useremailclaim_edited.send(target) +@event.listens_for(AccountEmailClaim, 'after_update') +def _accountemailclaim_edited(_mapper, _connection, target): + model_accountemailclaim_edited.send(target) -@event.listens_for(UserEmailClaim, 'after_delete') -def _useremailclaim_deleted(_mapper, _connection, target): - model_useremailclaim_deleted.send(target) +@event.listens_for(AccountEmailClaim, 'after_delete') +def _accountemailclaim_deleted(_mapper, _connection, target): + model_accountemailclaim_deleted.send(target) -@event.listens_for(UserPhone, 'after_insert') -def _userphone_new(_mapper, _connection, target): - model_userphone_new.send(target) +@event.listens_for(AccountPhone, 'after_insert') +def _accountphone_new(_mapper, _connection, target): + model_accountphone_new.send(target) -@event.listens_for(UserPhone, 'after_update') -def _userphone_edited(_mapper, _connection, target): - model_userphone_edited.send(target) +@event.listens_for(AccountPhone, 'after_update') +def _accountphone_edited(_mapper, _connection, target): + model_accountphone_edited.send(target) -@event.listens_for(UserPhone, 'after_delete') -def _userphone_deleted(_mapper, _connection, target): - model_userphone_deleted.send(target) +@event.listens_for(AccountPhone, 'after_delete') +def _accountphone_deleted(_mapper, _connection, target): + model_accountphone_deleted.send(target) diff --git a/funnel/models/utils.py b/funnel/models/utils.py index 5b1fcc82c..3d7fa93f3 100644 --- a/funnel/models/utils.py +++ b/funnel/models/utils.py @@ -2,24 +2,31 @@ from __future__ import annotations -from typing import NamedTuple, Optional, Set, Union, overload +from typing import Literal, NamedTuple, overload -from sqlalchemy import PrimaryKeyConstraint, UniqueConstraint - -from typing_extensions import Literal import phonenumbers +from sqlalchemy import PrimaryKeyConstraint, UniqueConstraint from .. import app from ..typing import OptionalMigratedTables +from .account import ( + Account, + AccountEmail, + AccountEmailClaim, + AccountExternalId, + AccountPhone, + Anchor, + Model, + db, +) from .phone_number import PHONE_LOOKUP_REGIONS -from .user import Anchor, User, UserEmail, UserEmailClaim, UserExternalId, UserPhone, db __all__ = [ 'IncompleteUserMigrationError', - 'UserAndAnchor', + 'AccountAndAnchor', 'getextid', 'getuser', - 'merge_users', + 'merge_accounts', ] @@ -27,55 +34,59 @@ class IncompleteUserMigrationError(Exception): """Could not migrate users because of data conflicts.""" -class UserAndAnchor(NamedTuple): - """User and anchor used to find the user (usable as a 2-tuple).""" +class AccountAndAnchor(NamedTuple): + """Account and anchor used to find the user (usable as a 2-tuple).""" - user: Optional[User] - anchor: Optional[Anchor] + account: Account | None + anchor: Anchor | None @overload -def getuser(name: str) -> Optional[User]: +def getuser(name: str) -> Account | None: ... @overload -def getuser(name: str, anchor: Literal[False]) -> Optional[User]: +def getuser(name: str, anchor: Literal[False]) -> Account | None: ... @overload -def getuser(name: str, anchor: Literal[True]) -> UserAndAnchor: +def getuser(name: str, anchor: Literal[True]) -> AccountAndAnchor: ... -def getuser(name: str, anchor: bool = False) -> Union[Optional[User], UserAndAnchor]: +def getuser(name: str, anchor: bool = False) -> Account | AccountAndAnchor | None: """ - Get a user with a matching name, email address or phone number. + Get an account with a matching name, email address or phone number. - Optionally returns an anchor (phone or email) instead of the user account. + Optionally returns an anchor (phone or email) along with the account. """ + accountemail: AccountEmail | AccountEmailClaim | None = None + accountphone: AccountPhone | None = None # Treat an '@' or '~' prefix as a username lookup, removing the prefix if name.startswith('@') or name.startswith('~'): name = name[1:] # If there's an '@' in the middle, treat as an email address elif '@' in name: - useremail: Union[None, UserEmail, UserEmailClaim] - useremail = UserEmail.get(email=name) - if useremail is None: + accountemail = AccountEmail.get(email=name) + if accountemail is None: # If there's no verified email address, look for a claim. - useremail = ( - UserEmailClaim.all(email=name) - .order_by(UserEmailClaim.created_at) - .first() - ) - if useremail is not None and useremail.user.state.ACTIVE: + try: + accountemail = ( + AccountEmailClaim.all(email=name) + .order_by(AccountEmailClaim.created_at) + .first() + ) + except ValueError: + accountemail = None + if accountemail is not None and accountemail.account.state.ACTIVE: # Return user only if in active state if anchor: - return UserAndAnchor(useremail.user, useremail) - return useremail.user + return AccountAndAnchor(accountemail.account, accountemail) + return accountemail.account if anchor: - return UserAndAnchor(None, None) + return AccountAndAnchor(None, None) return None else: # If it wasn't an email address or an @username, check if it's a phone number @@ -91,92 +102,113 @@ def getuser(name: str, anchor: bool = False) -> Union[Optional[User], UserAndAnc number = phonenumbers.format_number( parsed_number, phonenumbers.PhoneNumberFormat.E164 ) - userphone = UserPhone.get(number) - if userphone is not None and userphone.user.state.ACTIVE: + accountphone = AccountPhone.get(number) + if accountphone is not None and accountphone.account.state.ACTIVE: if anchor: - return UserAndAnchor(userphone.user, userphone) - return userphone.user - # No matching userphone? Continue to trying as a username + return AccountAndAnchor(accountphone.account, accountphone) + return accountphone.account + # No matching accountphone? Continue to trying as a username except phonenumbers.NumberParseException: # This was not a parseable phone number. Continue to trying as a username pass # Last guess: username - user = User.get(username=name) + user = Account.get(name=name) # If the caller wanted an anchor, try to return one (phone, then email) instead of # the user account if anchor: if user is None: - return UserAndAnchor(None, None) + return AccountAndAnchor(None, None) if user.phone: - return UserAndAnchor(user, user.phone) - useremail = user.default_email() - if useremail: - return UserAndAnchor(user, useremail) + return AccountAndAnchor(user, user.phone) + accountemail = user.default_email() + if accountemail: + return AccountAndAnchor(user, accountemail) # This user has no anchors - return UserAndAnchor(user, None) + return AccountAndAnchor(user, None) # Anchor not requested. Return the user account return user -def getextid(service: str, userid: str) -> Optional[UserExternalId]: +def getextid(service: str, userid: str) -> AccountExternalId | None: """Return a matching external id.""" - return UserExternalId.get(service=service, userid=userid) + return AccountExternalId.get(service=service, userid=userid) -def merge_users(user1: User, user2: User) -> Optional[User]: +def merge_accounts(current_account: Account, other_account: Account) -> Account | None: """Merge two user accounts and return the new user account.""" - app.logger.info("Preparing to merge users %s and %s", user1, user2) - # Always keep the older account and merge from the newer account - if user1.created_at < user2.created_at: - keep_user, merge_user = user1, user2 + app.logger.info( + "Preparing to merge accounts %s and %s", current_account, other_account + ) + # Always keep the older account and merge from the newer account. This keeps the + # UUID stable when there are multiple mergers as new accounts are easy to create, + # but old accounts cannot be created. + current_account_date = current_account.joined_at or current_account.created_at + other_account_date = other_account.joined_at or other_account.created_at + if current_account_date < other_account_date: + keep_account, merge_account = current_account, other_account else: - keep_user, merge_user = user2, user1 + keep_account, merge_account = other_account, current_account - # 1. Inspect all tables for foreign key references to merge_user and switch to - # keep_user. - safe = do_migrate_instances(merge_user, keep_user, 'migrate_user') + # 1. Inspect all tables for foreign key references to merge_account and switch to + # keep_account. + safe = do_migrate_instances(merge_account, keep_account, 'migrate_account') if safe: - # 2. Add merge_user's uuid to olduserids and mark user as merged - merge_user.mark_merged_into(keep_user) - # 3. Commit all of this + # 2. Add merge_account's uuid to oldids and mark account as merged + merge_account.mark_merged_into(keep_account) + # 3. Transfer name and password if required + if not keep_account.name: + name = merge_account.name + merge_account.name = None + # Push this change to the db so that the name can be re-assigned + db.session.flush() + keep_account.name = name + if keep_account == other_account: + # The user's currently logged in account is being discarded, so transfer + # their password over + keep_account.pw_hash = merge_account.pw_hash + keep_account.pw_set_at = merge_account.pw_set_at + keep_account.pw_expires_at = merge_account.pw_expires_at + merge_account.pw_hash = None + merge_account.pw_set_at = None + merge_account.pw_expires_at = None + + # 4. Commit all of this db.session.commit() # 4. Return keep_user. - app.logger.info("User merge complete, keeping user %s", keep_user) - return keep_user + app.logger.info("Account merge complete, keeping account %s", keep_account) + return keep_account - app.logger.error("User merge failed, aborting transaction") + app.logger.error("Account merge failed, aborting transaction") db.session.rollback() return None def do_migrate_instances( - old_instance: db.Model, # type: ignore[name-defined] - new_instance: db.Model, # type: ignore[name-defined] - helper_method: Optional[str] = None, + old_instance: Model, + new_instance: Model, + helper_method: str | None = None, ) -> bool: """ Migrate references to old instance of any model to provided new instance. - The model must derive from :class:`db.Model` and must have a single primary key + The model must derive from :class:`Model` and must have a single primary key column named ``id`` (typically provided by :class:`BaseMixin`). """ if old_instance == new_instance: raise ValueError("Old and new are the same") - # User id column (for foreign keys) - id_column = ( - old_instance.__class__.__table__.c.id - ) # 'id' is from IdMixin via BaseMixin + # User id column (for foreign keys); 'id' is from IdMixin via BaseMixin + id_column = old_instance.__class__.__table__.c.id # Session (for queries) session = old_instance.query.session # Keep track of all migrated tables - migrated_tables: Set[str] = set() + migrated_tables: set[str] = set() safe_to_remove_instance = True def do_migrate_table(table): @@ -196,9 +228,8 @@ def do_migrate_table(table): # will have a unique index but no model on which to place # helper_method, unless one of the related models handles # migrations AND signals a way for this table to be skipped - # here. This is why model.helper_method below (migrate_user or - # migrate_profile) returns a list of table names it has - # processed. + # here. This is why model.helper_method below (migrate_account) returns + # a list of table names it has processed. app.logger.error( "do_migrate_table interrupted because column is unique: {column}", extra={'column': column}, @@ -210,7 +241,7 @@ def do_migrate_table(table): if isinstance(constraint, (PrimaryKeyConstraint, UniqueConstraint)): for column in constraint.columns: if column in target_columns: - # The target column (typically user_id) is part of a unique + # The target column (typically account_id) is part of a unique # or primary key constraint. We can't migrate automatically. app.logger.error( "do_migrate_table interrupted because column is part of a" @@ -248,7 +279,7 @@ def do_migrate_table(table): return True # Look up all subclasses of the base class - for model in db.Model.__subclasses__(): + for model in Model.__subclasses__(): if model != old_instance.__class__: if helper_method and hasattr(model, helper_method): try: @@ -274,7 +305,7 @@ def do_migrate_table(table): migrated_tables.add(model.__table__.name) # Now look in the metadata for any tables we missed - for table in db.Model.metadata.tables.values(): + for table in Model.metadata.tables.values(): if table.name not in migrated_tables: if not do_migrate_table(table): safe_to_remove_instance = False diff --git a/funnel/models/venue.py b/funnel/models/venue.py index f78cbc4d5..620da0b01 100644 --- a/funnel/models/venue.py +++ b/funnel/models/venue.py @@ -2,9 +2,6 @@ from __future__ import annotations -from typing import List -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - from sqlalchemy.ext.orderinglist import ordering_list from coaster.sqlalchemy import add_primary_relationship, with_roles @@ -13,44 +10,40 @@ BaseScopedNameMixin, CoordinatesMixin, Mapped, - MarkdownCompositeBasic, + Model, UuidMixin, - db, + relationship, sa, ) -from .helpers import reopen +from .helpers import MarkdownCompositeBasic, reopen from .project import Project -from .project_membership import project_child_role_map +from .project_membership import project_child_role_map, project_child_role_set __all__ = ['Venue', 'VenueRoom'] -class Venue( - UuidMixin, - BaseScopedNameMixin, - CoordinatesMixin, - db.Model, # type: ignore[name-defined] -): +class Venue(UuidMixin, BaseScopedNameMixin, CoordinatesMixin, Model): __tablename__ = 'venue' - __allow_unmapped__ = True - project_id = sa.Column(sa.Integer, sa.ForeignKey('project.id'), nullable=False) + project_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('project.id'), nullable=False + ) project: Mapped[Project] = with_roles( - sa.orm.relationship(Project, back_populates='venues'), + relationship(Project, back_populates='venues'), grants_via={None: project_child_role_map}, ) parent: Mapped[Project] = sa.orm.synonym('project') - description = MarkdownCompositeBasic.create( + description, description_text, description_html = MarkdownCompositeBasic.create( 'description', default='', nullable=False ) - address1 = sa.Column(sa.Unicode(160), default='', nullable=False) - address2 = sa.Column(sa.Unicode(160), default='', nullable=False) - city = sa.Column(sa.Unicode(30), default='', nullable=False) - state = sa.Column(sa.Unicode(30), default='', nullable=False) - postcode = sa.Column(sa.Unicode(20), default='', nullable=False) - country = sa.Column(sa.Unicode(2), default='', nullable=False) - - rooms: Mapped[List[VenueRoom]] = sa.orm.relationship( + address1 = sa.orm.mapped_column(sa.Unicode(160), default='', nullable=False) + address2 = sa.orm.mapped_column(sa.Unicode(160), default='', nullable=False) + city = sa.orm.mapped_column(sa.Unicode(30), default='', nullable=False) + state = sa.orm.mapped_column(sa.Unicode(30), default='', nullable=False) + postcode = sa.orm.mapped_column(sa.Unicode(20), default='', nullable=False) + country = sa.orm.mapped_column(sa.Unicode(2), default='', nullable=False) + + rooms: Mapped[list[VenueRoom]] = relationship( 'VenueRoom', cascade='all', order_by='VenueRoom.seq', @@ -58,7 +51,7 @@ class Venue( back_populates='venue', ) - seq = sa.Column(sa.Integer, nullable=False) + seq = sa.orm.mapped_column(sa.Integer, nullable=False) __table_args__ = (sa.UniqueConstraint('project_id', 'name'),) @@ -109,23 +102,24 @@ class Venue( } -class VenueRoom(UuidMixin, BaseScopedNameMixin, db.Model): # type: ignore[name-defined] +class VenueRoom(UuidMixin, BaseScopedNameMixin, Model): __tablename__ = 'venue_room' - __allow_unmapped__ = True - venue_id = sa.Column(sa.Integer, sa.ForeignKey('venue.id'), nullable=False) + venue_id = sa.orm.mapped_column( + sa.Integer, sa.ForeignKey('venue.id'), nullable=False + ) venue: Mapped[Venue] = with_roles( - sa.orm.relationship(Venue, back_populates='rooms'), + relationship(Venue, back_populates='rooms'), # Since Venue already remaps Project roles, we just want the remapped role names - grants_via={None: set(project_child_role_map.values())}, + grants_via={None: project_child_role_set}, ) parent: Mapped[Venue] = sa.orm.synonym('venue') - description = MarkdownCompositeBasic.create( + description, description_text, description_html = MarkdownCompositeBasic.create( 'description', default='', nullable=False ) - bgcolor = sa.Column(sa.Unicode(6), nullable=False, default='229922') + bgcolor = sa.orm.mapped_column(sa.Unicode(6), nullable=False, default='229922') - seq = sa.Column(sa.Integer, nullable=False) + seq = sa.orm.mapped_column(sa.Integer, nullable=False) __table_args__ = (sa.UniqueConstraint('venue_id', 'name'),) @@ -181,7 +175,7 @@ def scoped_name(self): @reopen(Project) class __Project: venues = with_roles( - sa.orm.relationship( + relationship( Venue, cascade='all', order_by='Venue.seq', diff --git a/funnel/models/video_mixin.py b/funnel/models/video_mixin.py index 3ce20c888..f8816dca7 100644 --- a/funnel/models/video_mixin.py +++ b/funnel/models/video_mixin.py @@ -2,10 +2,7 @@ from __future__ import annotations -from typing import Optional, Tuple -import urllib.parse - -from baseframe import _ +from furl import furl from . import declarative_mixin, sa @@ -16,78 +13,50 @@ class VideoError(Exception): """A video could not be processed (base exception).""" -def parse_video_url(video_url: str) -> Tuple[str, str]: +def parse_video_url(video_url: str) -> tuple[str, str]: video_source = 'raw' - video_id = video_url + video_id: str | None = video_url - parsed = urllib.parse.urlparse(video_url) - if parsed.netloc is None: - raise ValueError("Invalid video URL") + parsed = furl(video_url) + if not parsed.host: + raise ValueError("The video URL must be an absolute URL") - if parsed.netloc in ['youtube.com', 'www.youtube.com', 'm.youtube.com']: + if parsed.host in ('youtube.com', 'www.youtube.com', 'm.youtube.com'): + video_source = 'youtube' + video_id = None if parsed.path == '/watch': - queries = urllib.parse.parse_qs(parsed.query) - if 'v' in queries and queries['v']: - video_id = queries['v'][0] - video_source = 'youtube' - else: - raise ValueError( - f"{video_url}: YouTube video URLs need to be in the format:" - " https://www.youtube.com/watch?v=dQw4w9WgXcQ" - ) - elif parsed.path.startswith('/embed/'): - video_id = parsed.path[7:] - if video_id: - video_source = 'youtube' - else: - raise ValueError( - f"{video_url}: YouTube video URLs need to be in the format:" - " https://www.youtube.com/watch?v=dQw4w9WgXcQ" - ) - else: - raise ValueError( - f"{video_url}: YouTube video URLs need to be in the format:" - " https://www.youtube.com/watch?v=dQw4w9WgXcQ" - ) - elif parsed.netloc == 'youtu.be': - video_id = parsed.path.lstrip('/') - if video_id: - video_source = 'youtube' - else: - raise ValueError( - "YouTube short URLs need to be in the format:" - " https://youtu.be/dQw4w9WgXcQ" - ) - elif parsed.netloc in ['vimeo.com', 'www.vimeo.com']: - video_id = parsed.path.lstrip('/') - if video_id: - video_source = 'vimeo' - else: - raise ValueError( - "Vimeo video URLs need to be in the format:" - " https://vimeo.com/336892869" - ) - elif parsed.netloc == 'drive.google.com': - if parsed.path.startswith('/open'): - queries = urllib.parse.parse_qs(parsed.query) - if 'id' in queries and queries['id']: - video_id = queries['id'][0] - video_source = 'googledrive' - else: - raise ValueError( - _("This must be a shareable URL for a single file in Google Drive") - ) - elif parsed.path.startswith('/file/d/'): - video_id = parsed.path[8:] - if video_id.endswith('/view'): - video_id = video_id[:-5] - elif video_id.endswith('/preview'): - video_id = video_id[:-8] - video_source = 'googledrive' - else: - raise ValueError( - _("This must be a shareable URL for a single file in Google Drive") - ) + video_id = parsed.query.params.get('v') + elif len(parsed.path.segments) == 2 and parsed.path.segments[0] in ( + 'embed', + 'live', + ): + video_id = parsed.path.segments[1] + if not video_id: + raise ValueError("Unparseable YouTube URL") + + elif parsed.host == 'youtu.be': + video_source = 'youtube' + video_id = parsed.path.segments[0] + if not video_id: + raise ValueError("Unparseable YouTube URL") + elif parsed.host in ['vimeo.com', 'www.vimeo.com']: + video_source = 'vimeo' + video_id = parsed.path.segments[0] + if not video_id: + raise ValueError("Unparseable Vimeo URL") + elif parsed.host == 'drive.google.com': + video_source = 'googledrive' + video_id = None + if parsed.path.segments[0] == 'open': + video_id = parsed.query.params.get('id') + elif len(parsed.path.segments) > 2 and parsed.path.segments[:2] == [ + 'file', + 'd', + ]: + video_id = parsed.path.segments[2] + if not video_id: + raise ValueError("Unsupported Google Drive URL") + return video_source, video_id @@ -105,11 +74,11 @@ def make_video_url(video_source: str, video_id: str) -> str: @declarative_mixin class VideoMixin: - video_id = sa.Column(sa.UnicodeText, nullable=True) - video_source = sa.Column(sa.UnicodeText, nullable=True) + video_id = sa.orm.mapped_column(sa.UnicodeText, nullable=True) + video_source = sa.orm.mapped_column(sa.UnicodeText, nullable=True) @property - def video_url(self) -> Optional[str]: + def video_url(self) -> str | None: if self.video_source and self.video_id: return make_video_url(self.video_source, self.video_id) return None @@ -122,7 +91,7 @@ def video_url(self, value: str): self.video_source, self.video_id = parse_video_url(value) @property - def embeddable_video_url(self) -> Optional[str]: + def embeddable_video_url(self) -> str | None: if self.video_source: if self.video_source == 'youtube': return ( diff --git a/funnel/pages/about/policy/privacy.md b/funnel/pages/about/policy/privacy.md index 889b69361..496549e7a 100644 --- a/funnel/pages/about/policy/privacy.md +++ b/funnel/pages/about/policy/privacy.md @@ -64,7 +64,7 @@ We collect and store information from our Users for the following purposes inclu - To contact the User with regards to the services they have availed of - To contact you for promotional offers or market research purposes, with the User’s consent - To syndicate User’s publicly available data -- To syndicate User’s pesonally identifiable information with Hasgeek and it’s partners +- To syndicate User’s pesonally identifiable information with Hasgeek and its partners ### Disclosure diff --git a/funnel/proxies/request.py b/funnel/proxies/request.py index 675b3129b..c709ebb7a 100644 --- a/funnel/proxies/request.py +++ b/funnel/proxies/request.py @@ -2,21 +2,22 @@ from __future__ import annotations +from collections.abc import Callable from functools import wraps -from typing import Any, Callable, Optional, Set, TypeVar, cast +from typing import TYPE_CHECKING from flask import has_request_context, request from werkzeug.local import LocalProxy from werkzeug.utils import cached_property -from ..typing import ResponseType, ReturnDecorator +from ..typing import ResponseType, T __all__ = ['request_wants'] -TestFunc = TypeVar('TestFunc', bound=Callable[['RequestWants'], Any]) - -def test_uses(*headers: str) -> ReturnDecorator: +def test_uses( + *headers: str, +) -> Callable[[Callable[[RequestWants], T]], cached_property[T | None]]: """ Identify HTTP headers accessed in this test, to be set in the response Vary header. @@ -24,15 +25,15 @@ def test_uses(*headers: str) -> ReturnDecorator: method into a cached property. """ - def decorator(f: TestFunc) -> TestFunc: + def decorator(f: Callable[[RequestWants], T]) -> cached_property[T | None]: @wraps(f) - def wrapper(self: RequestWants) -> Any: + def wrapper(self: RequestWants) -> T | None: self.response_vary.update(headers) if not has_request_context(): - return False + return None return f(self) - return cast(TestFunc, cached_property(wrapper)) + return cached_property(wrapper) return decorator @@ -50,7 +51,7 @@ class RequestWants: """ def __init__(self) -> None: - self.response_vary: Set[str] = set() + self.response_vary: set[str] = set() def __bool__(self) -> bool: return has_request_context() @@ -95,27 +96,32 @@ def htmx(self) -> bool: return request.environ.get('HTTP_HX_REQUEST') == 'true' @test_uses('HX-Trigger') - def hx_trigger(self) -> Optional[str]: + def hx_trigger(self) -> str | None: """Id of element that triggered a HTMX request.""" return request.environ.get('HTTP_HX_TRIGGER') @test_uses('HX-Trigger-Name') - def hx_trigger_name(self) -> Optional[str]: + def hx_trigger_name(self) -> str | None: """Name of element that triggered a HTMX request.""" return request.environ.get('HTTP_HX_TRIGGER_NAME') @test_uses('HX-Target') - def hx_target(self) -> Optional[str]: + def hx_target(self) -> str | None: """Target of a HTMX request.""" return request.environ.get('HTTP_HX_TARGET') @test_uses('HX-Prompt') - def hx_prompt(self) -> Optional[str]: + def hx_prompt(self) -> str | None: """Content of user prompt in HTMX.""" return request.environ.get('HTTP_HX_PROMPT') # --- End of request_wants tests --------------------------------------------------- + if TYPE_CHECKING: + + def _get_current_object(self) -> RequestWants: + """Type hint for the LocalProxy wrapper method.""" + def _get_request_wants() -> RequestWants: """Get request_wants from the request.""" @@ -133,10 +139,10 @@ def _get_request_wants() -> RequestWants: return RequestWants() -request_wants = LocalProxy(_get_request_wants) +request_wants: RequestWants = LocalProxy(_get_request_wants) # type: ignore[assignment] def response_varies(response: ResponseType) -> ResponseType: """App ``after_request`` handler to set response ``Vary`` header.""" - response.vary.update(request_wants.response_vary) # type: ignore[union-attr] + response.vary.update(request_wants.response_vary) return response diff --git a/funnel/registry.py b/funnel/registry.py index dda43e5ea..fe75600ea 100644 --- a/funnel/registry.py +++ b/funnel/registry.py @@ -2,19 +2,21 @@ from __future__ import annotations +import re from collections import OrderedDict +from collections.abc import Callable, Collection from dataclasses import dataclass from functools import wraps -from typing import Callable, Collection, List, NoReturn, Optional, Tuple, cast -import re +from typing import Any, NoReturn from flask import Response, abort, jsonify, request +from werkzeug.datastructures import MultiDict from baseframe import _ from baseframe.signals import exception_catchall -from .models import AuthToken, UserExternalId -from .typing import ReturnDecorator, ReturnResponse, WrappedFunc +from .models import AccountExternalId, AuthToken +from .typing import P, ReturnResponse # Bearer token, as per # http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-15#section-2.1 @@ -27,10 +29,10 @@ class ResourceRegistry(OrderedDict): def resource( self, name: str, - description: Optional[str] = None, + description: str | None = None, trusted: bool = False, - scope: Optional[str] = None, - ) -> ReturnDecorator: + scope: str | None = None, + ) -> Callable[[Callable[P, Any]], Callable[[], ReturnResponse]]: """ Decorate a resource function. @@ -56,7 +58,9 @@ def resource_auth_error(message: str) -> Response: }, ) - def decorator(f: WrappedFunc) -> Callable[..., ReturnResponse]: + def decorator( + f: Callable[[AuthToken, MultiDict, MultiDict], Any] + ) -> Callable[[], ReturnResponse]: @wraps(f) def wrapper() -> ReturnResponse: if request.method == 'GET': @@ -87,9 +91,8 @@ def wrapper() -> ReturnResponse: if not authtoken.is_valid(): return resource_auth_error(_("Access token has expired")) - tokenscope = set( - authtoken.effective_scope - ) # Read once to avoid reparsing below + # Read once to avoid reparsing below + tokenscope = set(authtoken.effective_scope) wildcardscope = usescope.split('/', 1)[0] + '/*' if not (authtoken.auth_client.trusted and '*' in tokenscope): # If a trusted client has '*' in token scope, all good, @@ -134,7 +137,7 @@ def wrapper() -> ReturnResponse: 'trusted': trusted, 'f': f, } - return cast(WrappedFunc, wrapper) + return wrapper return decorator @@ -144,40 +147,40 @@ class LoginProviderData: """User data supplied by a LoginProvider.""" userid: str - username: Optional[str] = None - avatar_url: Optional[str] = None - oauth_token: Optional[str] = None - oauth_token_secret: Optional[str] = None # Only used in OAuth1a - oauth_token_type: Optional[str] = None - oauth_refresh_token: Optional[str] = None - oauth_expires_in: Optional[int] = None - email: Optional[str] = None + username: str | None = None + avatar_url: str | None = None + oauth_token: str | None = None + oauth_token_secret: str | None = None # Only used in OAuth1a + oauth_token_type: str | None = None + oauth_refresh_token: str | None = None + oauth_expires_in: int | None = None + email: str | None = None emails: Collection[str] = () - emailclaim: Optional[str] = None - phone: Optional[str] = None - fullname: Optional[str] = None + emailclaim: str | None = None + phone: str | None = None + fullname: str | None = None class LoginProviderRegistry(OrderedDict): """Registry of login providers.""" - def at_username_services(self) -> List[str]: + def at_username_services(self) -> list[str]: """Return services which typically use ``@username`` addressing.""" return [key for key in self if self[key].at_username] - def at_login_items(self) -> List[Tuple[str, LoginProvider]]: + def at_login_items(self) -> list[tuple[str, LoginProvider]]: """Return services which have the flag at_login set to True.""" return [(k, v) for (k, v) in self.items() if v.at_login is True] def __setitem__(self, key: str, value: LoginProvider) -> None: """Make a registry entry.""" super().__setitem__(key, value) - UserExternalId.__at_username_services__ = self.at_username_services() + AccountExternalId.__at_username_services__ = self.at_username_services() def __delitem__(self, key: str) -> None: """Remove a registry entry.""" super().__delitem__(key) - UserExternalId.__at_username_services__ = self.at_username_services() + AccountExternalId.__at_username_services__ = self.at_username_services() class LoginError(Exception): @@ -222,14 +225,14 @@ class LoginProvider: #: used for addressing with @username at_username = False - def __init__( # pylint: disable=too-many-arguments + def __init__( self, name: str, title: str, key: str, secret: str, at_login: bool = True, - icon: Optional[str] = None, + icon: str | None = None, **kwargs, ) -> None: self.name = name diff --git a/funnel/serializers.py b/funnel/serializers.py index d1236ebb3..1bbb5ec85 100644 --- a/funnel/serializers.py +++ b/funnel/serializers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from itsdangerous import JSONWebSignatureSerializer, URLSafeTimedSerializer +from itsdangerous import URLSafeTimedSerializer from coaster.app import KeyRotationWrapper @@ -12,7 +12,7 @@ # Lastuser cookie serializer def lastuser_serializer() -> KeyRotationWrapper: return KeyRotationWrapper( - JSONWebSignatureSerializer, app.config['LASTUSER_SECRET_KEYS'] + URLSafeTimedSerializer, app.config['LASTUSER_SECRET_KEYS'] ) diff --git a/funnel/signals.py b/funnel/signals.py index 141a8834b..ae1742ea1 100644 --- a/funnel/signals.py +++ b/funnel/signals.py @@ -24,27 +24,29 @@ model_team_edited = model_signals.signal('model-team-edited') model_team_deleted = model_signals.signal('model-team-deleted') -model_useremail_new = model_signals.signal('model-useremail-new') -model_useremail_edited = model_signals.signal('model-useremail-edited') -model_useremail_deleted = model_signals.signal('model-useremail-deleted') - -model_useremailclaim_new = model_signals.signal('model-useremail-new') -model_useremailclaim_edited = model_signals.signal('model-useremail-edited') -model_useremailclaim_deleted = model_signals.signal('model-useremail-deleted') +model_accountemail_new = model_signals.signal('model-accountemail-new') +model_accountemail_edited = model_signals.signal('model-accountemail-edited') +model_accountemail_deleted = model_signals.signal('model-accountemail-deleted') + +model_accountemailclaim_new = model_signals.signal('model-accountemailclaim-new') +model_accountemailclaim_edited = model_signals.signal('model-accountemailclaim-edited') +model_accountemailclaim_deleted = model_signals.signal( + 'model-accountemailclaim-deleted' +) -model_userphone_new = model_signals.signal('model-useremail-new') -model_userphone_edited = model_signals.signal('model-useremail-edited') -model_userphone_deleted = model_signals.signal('model-useremail-deleted') +model_accountphone_new = model_signals.signal('model-accountphone-new') +model_accountphone_edited = model_signals.signal('model-accountphone-edited') +model_accountphone_deleted = model_signals.signal('model-accountphone-deleted') resource_access_granted = model_signals.signal('resource-access-granted') emailaddress_refcount_dropping = model_signals.signal( 'emailaddress-refcount-dropping', - doc="Signal indicating that an EmailAddress's refcount is about to drop", + doc="Signal indicating that an EmailAddress’s refcount is about to drop", ) phonenumber_refcount_dropping = model_signals.signal( 'phonenumber-refcount-dropping', - doc="Signal indicating that an PhoneNumber's refcount is about to drop", + doc="Signal indicating that a PhoneNumber’s refcount is about to drop", ) # Higher level signals @@ -54,6 +56,7 @@ org_data_changed = app_signals.signal('org-data-changed') team_data_changed = app_signals.signal('team-data-changed') session_revoked = app_signals.signal('session-revoked') +project_data_change = app_signals.signal('project-data-change') # Commentset role change signals (sends user, document) project_role_change = app_signals.signal('project_role_change') diff --git a/funnel/static/css/app.css b/funnel/static/css/app.css index 10bcf7914..1bf8afbd2 100644 --- a/funnel/static/css/app.css +++ b/funnel/static/css/app.css @@ -1884,35 +1884,51 @@ h6 { } .mui-btn:hover, .mui-btn:focus { - box-shadow: 0 0px 2px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: + 0 0px 2px rgba(0, 0, 0, 0.12), + 0 2px 2px rgba(0, 0, 0, 0.2); } @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { .mui-btn:hover, .mui-btn:focus { - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.12), -1px 0px 2px rgba(0, 0, 0, 0.12), - 0 0px 2px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: + 0 -1px 2px rgba(0, 0, 0, 0.12), + -1px 0px 2px rgba(0, 0, 0, 0.12), + 0 0px 2px rgba(0, 0, 0, 0.12), + 0 2px 2px rgba(0, 0, 0, 0.2); } } @supports (-ms-ime-align: auto) { .mui-btn:hover, .mui-btn:focus { - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.12), -1px 0px 2px rgba(0, 0, 0, 0.12), - 0 0px 2px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: + 0 -1px 2px rgba(0, 0, 0, 0.12), + -1px 0px 2px rgba(0, 0, 0, 0.12), + 0 0px 2px rgba(0, 0, 0, 0.12), + 0 2px 2px rgba(0, 0, 0, 0.2); } } .mui-btn:active:hover { - box-shadow: 0 0px 4px rgba(0, 0, 0, 0.12), 1px 3px 4px rgba(0, 0, 0, 0.2); + box-shadow: + 0 0px 4px rgba(0, 0, 0, 0.12), + 1px 3px 4px rgba(0, 0, 0, 0.2); } @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { .mui-btn:active:hover { - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.12), -1px 0px 2px rgba(0, 0, 0, 0.12), - 0 0px 4px rgba(0, 0, 0, 0.12), 1px 3px 4px rgba(0, 0, 0, 0.2); + box-shadow: + 0 -1px 2px rgba(0, 0, 0, 0.12), + -1px 0px 2px rgba(0, 0, 0, 0.12), + 0 0px 4px rgba(0, 0, 0, 0.12), + 1px 3px 4px rgba(0, 0, 0, 0.2); } } @supports (-ms-ime-align: auto) { .mui-btn:active:hover { - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.12), -1px 0px 2px rgba(0, 0, 0, 0.12), - 0 0px 4px rgba(0, 0, 0, 0.12), 1px 3px 4px rgba(0, 0, 0, 0.2); + box-shadow: + 0 -1px 2px rgba(0, 0, 0, 0.12), + -1px 0px 2px rgba(0, 0, 0, 0.12), + 0 0px 4px rgba(0, 0, 0, 0.12), + 1px 3px 4px rgba(0, 0, 0, 0.2); } } .mui-btn:disabled, @@ -1940,38 +1956,54 @@ h6 { .mui-btn--raised, .mui-btn--fab { - box-shadow: 0 0px 2px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: + 0 0px 2px rgba(0, 0, 0, 0.12), + 0 2px 2px rgba(0, 0, 0, 0.2); } @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { .mui-btn--raised, .mui-btn--fab { - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.12), -1px 0px 2px rgba(0, 0, 0, 0.12), - 0 0px 2px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: + 0 -1px 2px rgba(0, 0, 0, 0.12), + -1px 0px 2px rgba(0, 0, 0, 0.12), + 0 0px 2px rgba(0, 0, 0, 0.12), + 0 2px 2px rgba(0, 0, 0, 0.2); } } @supports (-ms-ime-align: auto) { .mui-btn--raised, .mui-btn--fab { - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.12), -1px 0px 2px rgba(0, 0, 0, 0.12), - 0 0px 2px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: + 0 -1px 2px rgba(0, 0, 0, 0.12), + -1px 0px 2px rgba(0, 0, 0, 0.12), + 0 0px 2px rgba(0, 0, 0, 0.12), + 0 2px 2px rgba(0, 0, 0, 0.2); } } .mui-btn--raised:active, .mui-btn--fab:active { - box-shadow: 0 0px 4px rgba(0, 0, 0, 0.12), 1px 3px 4px rgba(0, 0, 0, 0.2); + box-shadow: + 0 0px 4px rgba(0, 0, 0, 0.12), + 1px 3px 4px rgba(0, 0, 0, 0.2); } @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { .mui-btn--raised:active, .mui-btn--fab:active { - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.12), -1px 0px 2px rgba(0, 0, 0, 0.12), - 0 0px 4px rgba(0, 0, 0, 0.12), 1px 3px 4px rgba(0, 0, 0, 0.2); + box-shadow: + 0 -1px 2px rgba(0, 0, 0, 0.12), + -1px 0px 2px rgba(0, 0, 0, 0.12), + 0 0px 4px rgba(0, 0, 0, 0.12), + 1px 3px 4px rgba(0, 0, 0, 0.2); } } @supports (-ms-ime-align: auto) { .mui-btn--raised:active, .mui-btn--fab:active { - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.12), -1px 0px 2px rgba(0, 0, 0, 0.12), - 0 0px 4px rgba(0, 0, 0, 0.12), 1px 3px 4px rgba(0, 0, 0, 0.2); + box-shadow: + 0 -1px 2px rgba(0, 0, 0, 0.12), + -1px 0px 2px rgba(0, 0, 0, 0.12), + 0 0px 4px rgba(0, 0, 0, 0.12), + 1px 3px 4px rgba(0, 0, 0, 0.2); } } @@ -2429,7 +2461,9 @@ h6 { margin-bottom: 21px; border-radius: 0; background-color: #fff; - box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0px 2px 0 rgba(0, 0, 0, 0.12); + box-shadow: + 0 2px 2px 0 rgba(0, 0, 0, 0.16), + 0 0px 2px 0 rgba(0, 0, 0, 0.12); } .mui-panel:before, .mui-panel:after { @@ -2441,14 +2475,20 @@ h6 { } @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { .mui-panel { - box-shadow: 0 -1px 2px 0 rgba(0, 0, 0, 0.12), -1px 0px 2px 0 rgba(0, 0, 0, 0.12), - 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0px 2px 0 rgba(0, 0, 0, 0.12); + box-shadow: + 0 -1px 2px 0 rgba(0, 0, 0, 0.12), + -1px 0px 2px 0 rgba(0, 0, 0, 0.12), + 0 2px 2px 0 rgba(0, 0, 0, 0.16), + 0 0px 2px 0 rgba(0, 0, 0, 0.12); } } @supports (-ms-ime-align: auto) { .mui-panel { - box-shadow: 0 -1px 2px 0 rgba(0, 0, 0, 0.12), -1px 0px 2px 0 rgba(0, 0, 0, 0.12), - 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0px 2px 0 rgba(0, 0, 0, 0.12); + box-shadow: + 0 -1px 2px 0 rgba(0, 0, 0, 0.12), + -1px 0px 2px 0 rgba(0, 0, 0, 0.12), + 0 2px 2px 0 rgba(0, 0, 0, 0.16), + 0 0px 2px 0 rgba(0, 0, 0, 0.12); } } @@ -3137,23 +3177,33 @@ h6 { .thumbnail, .control-icon, .jquery-modal.blocker.current .modal { - 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); } .mui--z2 { - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); + box-shadow: + 0 3px 6px rgba(0, 0, 0, 0.16), + 0 3px 6px rgba(0, 0, 0, 0.23); } .mui--z3 { - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); + box-shadow: + 0 10px 20px rgba(0, 0, 0, 0.19), + 0 6px 6px rgba(0, 0, 0, 0.23); } .mui--z4 { - 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); } .mui--z5 { - box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22); + box-shadow: + 0 19px 38px rgba(0, 0, 0, 0.3), + 0 15px 12px rgba(0, 0, 0, 0.22); } .mui--clearfix:before, @@ -3441,8 +3491,10 @@ h6 { } .mui-ripple.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); } .mui-ripple.mui--is-visible { @@ -3931,7 +3983,9 @@ h6 { 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); } .mui-dropdown__menu > li > a { padding: 8px 16px; @@ -4505,7 +4559,9 @@ blockquote { margin: 8px 0 25px; background-color: #f5f5f5; border-radius: 4px; - 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); position: relative; display: none; } @@ -4580,7 +4636,9 @@ blockquote { 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); } @@ -6359,7 +6417,9 @@ a.loginbutton.hidden, bottom: 52px; width: 100%; left: 0; - 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); display: none; } .header--fixed @@ -7032,7 +7092,9 @@ a.loginbutton.hidden, .header--fixed .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; @@ -7198,7 +7260,9 @@ a.loginbutton.hidden, 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 .sub-navbar__item:nth-child(2) { border-top: 1px solid rgba(132, 146, 166, 0.3); @@ -8831,7 +8895,9 @@ form.mui--bg-accent .CodeMirror { .card--spotlight:hover, .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) { @@ -9446,7 +9512,9 @@ form.mui--bg-accent .CodeMirror { @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); } } .project-section__map { @@ -9814,13 +9882,27 @@ form.mui--bg-accent .CodeMirror { width: calc(100% - 16px); margin: 0px; padding: 8px; - 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); 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%; } .label-dropdown-wrapper fieldset legend { @@ -9830,11 +9912,26 @@ form.mui--bg-accent .CodeMirror { 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; } .label-dropdown-wrapper .listwidget .mui-textfield { margin-bottom: 0; diff --git a/funnel/static/css/spectrum.css b/funnel/static/css/spectrum.css index db5b213c8..372d0e731 100644 --- a/funnel/static/css/spectrum.css +++ b/funnel/static/css/spectrum.css @@ -335,8 +335,13 @@ See http://bgrins.github.io/spectrum/themes/ for instructions. .sp-color, .sp-hue, .sp-clear { - font: normal 12px 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Geneva, - Verdana, sans-serif; + font: + normal 12px 'Lucida Grande', + 'Lucida Sans Unicode', + 'Lucida Sans', + Geneva, + Verdana, + sans-serif; -webkit-box-sizing: border-box; -moz-box-sizing: border-box; -ms-box-sizing: border-box; @@ -526,11 +531,21 @@ See http://bgrins.github.io/spectrum/themes/ for instructions. .sp-container button:active { border: 1px solid #aaa; border-bottom: 1px solid #888; - -webkit-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; - -moz-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; - -ms-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; - -o-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; - box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; + -webkit-box-shadow: + inset 0 0 5px 2px #aaaaaa, + 0 1px 0 0 #eeeeee; + -moz-box-shadow: + inset 0 0 5px 2px #aaaaaa, + 0 1px 0 0 #eeeeee; + -ms-box-shadow: + inset 0 0 5px 2px #aaaaaa, + 0 1px 0 0 #eeeeee; + -o-box-shadow: + inset 0 0 5px 2px #aaaaaa, + 0 1px 0 0 #eeeeee; + box-shadow: + inset 0 0 5px 2px #aaaaaa, + 0 1px 0 0 #eeeeee; } .sp-cancel { font-size: 11px; diff --git a/funnel/static/img/community.svg b/funnel/static/img/community.svg index b67e7d263..200ea9639 100644 --- a/funnel/static/img/community.svg +++ b/funnel/static/img/community.svg @@ -1,735 +1,735 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/funnel/static/img/conversation.svg b/funnel/static/img/conversation.svg index 94e68bcf0..43013ae36 100644 --- a/funnel/static/img/conversation.svg +++ b/funnel/static/img/conversation.svg @@ -1,407 +1,407 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/funnel/static/img/email/chars-v1/access-granted.png b/funnel/static/img/email/chars-v1/access-granted.png new file mode 100644 index 000000000..2d1927a77 Binary files /dev/null and b/funnel/static/img/email/chars-v1/access-granted.png differ diff --git a/funnel/static/img/email/chars-v1/access-revoked.png b/funnel/static/img/email/chars-v1/access-revoked.png new file mode 100644 index 000000000..20cfdb1e4 Binary files /dev/null and b/funnel/static/img/email/chars-v1/access-revoked.png differ diff --git a/funnel/static/img/email/chars-v1/admin-report.png b/funnel/static/img/email/chars-v1/admin-report.png new file mode 100644 index 000000000..00a9182b7 Binary files /dev/null and b/funnel/static/img/email/chars-v1/admin-report.png differ diff --git a/funnel/static/img/email/chars-v1/comment.png b/funnel/static/img/email/chars-v1/comment.png new file mode 100644 index 000000000..9f2c02860 Binary files /dev/null and b/funnel/static/img/email/chars-v1/comment.png differ diff --git a/funnel/static/img/email/chars-v1/new-submission.png b/funnel/static/img/email/chars-v1/new-submission.png new file mode 100644 index 000000000..f5ce00b10 Binary files /dev/null and b/funnel/static/img/email/chars-v1/new-submission.png differ diff --git a/funnel/static/img/email/chars-v1/password.png b/funnel/static/img/email/chars-v1/password.png new file mode 100644 index 000000000..677e140fd Binary files /dev/null and b/funnel/static/img/email/chars-v1/password.png differ diff --git a/funnel/static/img/email/chars-v1/registration-cancelled.png b/funnel/static/img/email/chars-v1/registration-cancelled.png new file mode 100644 index 000000000..9cb3e4afb Binary files /dev/null and b/funnel/static/img/email/chars-v1/registration-cancelled.png differ diff --git a/funnel/static/img/email/chars-v1/registration-confirmed.png b/funnel/static/img/email/chars-v1/registration-confirmed.png new file mode 100644 index 000000000..e8f758eba Binary files /dev/null and b/funnel/static/img/email/chars-v1/registration-confirmed.png differ diff --git a/funnel/static/img/email/chars-v1/sent-submission.png b/funnel/static/img/email/chars-v1/sent-submission.png new file mode 100644 index 000000000..0fbedc7ee Binary files /dev/null and b/funnel/static/img/email/chars-v1/sent-submission.png differ diff --git a/funnel/static/img/email/chars-v1/session-starting.png b/funnel/static/img/email/chars-v1/session-starting.png new file mode 100644 index 000000000..b9700ad4e Binary files /dev/null and b/funnel/static/img/email/chars-v1/session-starting.png differ diff --git a/funnel/static/img/email/chars-v1/update.png b/funnel/static/img/email/chars-v1/update.png new file mode 100644 index 000000000..0b283a6aa Binary files /dev/null and b/funnel/static/img/email/chars-v1/update.png differ diff --git a/funnel/static/img/email/logo-puzzle.png b/funnel/static/img/email/logo-puzzle.png new file mode 100644 index 000000000..6ca01c575 Binary files /dev/null and b/funnel/static/img/email/logo-puzzle.png differ diff --git a/funnel/static/img/error-403.svg b/funnel/static/img/error-403.svg index 5c40616ee..1252a2a11 100644 --- a/funnel/static/img/error-403.svg +++ b/funnel/static/img/error-403.svg @@ -1,54 +1,54 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/funnel/static/img/error-404.svg b/funnel/static/img/error-404.svg index dde135437..3257fcbf2 100644 --- a/funnel/static/img/error-404.svg +++ b/funnel/static/img/error-404.svg @@ -1,34 +1,34 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/funnel/static/img/error-405.svg b/funnel/static/img/error-405.svg index b80b54223..444357efb 100644 --- a/funnel/static/img/error-405.svg +++ b/funnel/static/img/error-405.svg @@ -1,50 +1,50 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/funnel/static/img/error-410.svg b/funnel/static/img/error-410.svg index f87b1baab..b20524f41 100644 --- a/funnel/static/img/error-410.svg +++ b/funnel/static/img/error-410.svg @@ -1,64 +1,64 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/funnel/static/img/error-429.svg b/funnel/static/img/error-429.svg index 14ba16f41..0bdcc28a6 100644 --- a/funnel/static/img/error-429.svg +++ b/funnel/static/img/error-429.svg @@ -1,51 +1,51 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/funnel/static/img/error-500.svg b/funnel/static/img/error-500.svg index 169a4b98f..4b5517701 100644 --- a/funnel/static/img/error-500.svg +++ b/funnel/static/img/error-500.svg @@ -1,59 +1,59 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/funnel/static/img/error-503.svg b/funnel/static/img/error-503.svg index a36f9d1ba..f079bb51a 100644 --- a/funnel/static/img/error-503.svg +++ b/funnel/static/img/error-503.svg @@ -1,64 +1,64 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/funnel/static/img/hg-logo.svg b/funnel/static/img/hg-logo.svg index bce5da0fe..2ecbc896e 100644 --- a/funnel/static/img/hg-logo.svg +++ b/funnel/static/img/hg-logo.svg @@ -1,20 +1,20 @@ - - - - - + + + + + diff --git a/funnel/static/img/peers.svg b/funnel/static/img/peers.svg index 2e439e976..c72bc410b 100644 --- a/funnel/static/img/peers.svg +++ b/funnel/static/img/peers.svg @@ -1,471 +1,471 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/funnel/static/js/schedules.js b/funnel/static/js/schedules.js index 160e2814f..9eefcb862 100644 --- a/funnel/static/js/schedules.js +++ b/funnel/static/js/schedules.js @@ -35,7 +35,15 @@ function invert(color) { return rgbToHex(color); } -toastr.options = { +function activate_widgets() { + /* Upgrade to jquery 3.6 select2 autofocus isn't working. This is to fix that problem. + select2/select2#5993 */ + $(document).on('select2:open', function () { + document.querySelector('.select2-search__field').focus(); + }); +} + +window.toastr.options = { positionClass: 'toast-bottom-right', }; @@ -82,7 +90,8 @@ $(function () { }); calendar.render(); }); - this.color_form.submit(function () { + this.color_form.submit(function (event) { + event.preventDefault(); var json = {}; $('input[name="uuid"]').each(function (index, element) { @@ -114,13 +123,13 @@ $(function () { contentType: 'application/json', data: JSON.stringify(json), success: function (result) { - toastr.success( + window.toastr.success( gettext('The room sequence and colours have been updated') ); }, complete: function (xhr, type) { if (type == 'error' || type == 'timeout') { - toastr.error( + window.toastr.error( gettext( 'The server could not be reached. Check connection and try again' ) @@ -220,7 +229,7 @@ $(function () { }, complete: function (xhr, type) { if (type == 'error' || type == 'timeout') { - toastr.error( + window.toastr.error( gettext( 'The server could not be reached. Check connection and try again' ) @@ -263,7 +272,7 @@ $(function () { complete: function (xhr, type) { if (type == 'error' || type == 'timeout') { popup.close(); - toastr.error( + window.toastr.error( gettext('The server could not be reached. Check connection and try again') ); } @@ -467,13 +476,13 @@ $(function () { events.add_unscheduled(event.title, response.modal_url); obj.remove(event); if (response.msg) { - toastr.warning(response.message); + window.toastr.warning(response.message); } } }, complete: function (xhr, type) { if (type == 'error' || type == 'timeout') { - toastr.error( + window.toastr.error( gettext( 'The server could not be reached. Check connection and try again' ) @@ -703,14 +712,14 @@ $(function () { if (type == 'error' || type == 'timeout') { calendar.buttons.save.enable(gettext('Save')); if (e.length > 2) { - toastr.error( + window.toastr.error( gettext( 'The server could not be reached. There are %d unsaved sessions. Check connection and try again', e.length ) ); } else { - toastr.error( + window.toastr.error( gettext( 'The server could not be reached. There is 1 unsaved session. Check connection and try again' ) diff --git a/funnel/static/js/scripts.js b/funnel/static/js/scripts.js index dc961acfd..e69de29bb 100644 --- a/funnel/static/js/scripts.js +++ b/funnel/static/js/scripts.js @@ -1,542 +0,0 @@ -window.Hasgeek = {}; - -window.Hasgeek.Config = { - defaultLatitude: '12.961443', - defaultLongitude: '77.64435000000003', -}; - -function activate_widgets() { - /* Upgrade to jquery 3.6 select2 autofocus isn't working. This is to fix that problem. - select2/select2#5993 */ - $(document).on('select2:open', function () { - document.querySelector('.select2-search__field').focus(); - }); -} - -function radioHighlight(radioName, highlightClass) { - var selector = "input[name='" + radioName + "']"; - $(selector + ':checked') - .parent() - .addClass(highlightClass); - var handler = function () { - $(selector).parent().removeClass(highlightClass); - $(selector + ':checked') - .parent() - .addClass(highlightClass); - }; - $(selector).change(handler); - $(selector).click(handler); -} - -function activate_geoname_autocomplete( - selector, - autocomplete_endpoint, - getname_endpoint, - separator -) { - $(selector).select2({ - placeholder: 'Search for a location', - multiple: true, - minimumInputLength: 2, - ajax: { - url: autocomplete_endpoint, - dataType: 'jsonp', - data: function (params, page) { - return { - q: params.term, - }; - }, - processResults: function (data, page) { - var rdata = []; - if (data.status == 'ok') { - for (var i = 0; i < data.result.length; i++) { - rdata.push({ - id: data.result[i].geonameid, - text: data.result[i].picker_title, - }); - } - } - return { more: false, results: rdata }; - }, - }, - }); - - //Setting label for Geoname ids - var val = $(selector).val(); - if (val) { - val = val.map(function (id) { - return 'name=' + id; - }); - var qs = val.join('&'); - $.ajax(getname_endpoint + '?' + qs, { - accepts: 'application/json', - dataType: 'jsonp', - }).done(function (data) { - $(selector).empty(); - var rdata = []; - if (data.status == 'ok') { - for (var i = 0; i < data.result.length; i++) { - $(selector).append( - '' - ); - rdata.push(data.result[i].geonameid); - } - $(selector).val(rdata).trigger('change'); - } - }); - } -} - -function activateZoomPopup() { - if ($('.markdown').length > 0) { - $('abbr').each(function () { - if ($(this).offset().left > $(window).width() * 0.7) { - $(this).addClass('tooltip-right'); - } - }); - } - - $('body').on('click', '.markdown table, .markdown img', function (event) { - event.preventDefault(); - $('body').append('
'); - $('.markdown-modal').html($(this)[0].outerHTML); - $('.markdown-modal').modal(); - }); - - $('body').on('click', '.markdown table a', function (event) { - event.stopPropagation(); - }); - - $('body').on($.modal.AFTER_CLOSE, '.markdown-modal', function (event) { - event.preventDefault(); - $('.markdown-modal').remove(); - }); -} - -function addFocusOnModalShow() { - var focussedElem; - $('body').on($.modal.OPEN, '.modal', function () { - focussedElem = document.activeElement; - trapFocusWithinModal(this); - }); - - $('body').on($.modal.CLOSE, '.modal', function () { - focussedElem.focus(); - }); -} - -function trapFocusWithinModal(modal) { - var $this = $(modal); - var focusableElems = - 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]'; - var children = $this.find('*'); - var focusableItems = children.filter(focusableElems).filter(':visible'); - var numberOfFocusableItems = focusableItems.length; - var focusedItem, focusedItemIndex; - $this.find('.modal__close').focus(); - - $this.on('keydown', function (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(); - } - }); -} - -$(function () { - // activate all widgets - activate_widgets(); - - var matchtab = function () { - var url = document.location.toString(), - tabmatch = null; - if (url.match('#/')) { - tabmatch = $('.nav-tabs.nav-tabs-auto a[href="#' + url.split('#/')[1] + '"]'); - } else if (url.match('#')) { - tabmatch = $('.nav-tabs.nav-tabs-auto a[href="#' + url.split('#')[1] + '"]'); - } - if (tabmatch !== null && tabmatch.length !== 0) { - $(tabmatch[0]).tab('show'); - } - }; - - // Load correct tab when fragment identifier changes - $(window).bind('hashchange', matchtab); - // Load correct tab when the page loads - matchtab(); - // Change hash for tab click - $('.nav-tabs.nav-tabs-auto a').on('shown', function (e) { - window.location.hash = '#/' + e.target.hash.slice(1); - }); - var url = document.location.toString(); - if (!url.match('#')) { - // Activate the first tab if none are active - var tabmatch = $('.nav-tabs.nav-tabs-auto a').filter(':first'); - if (tabmatch.length !== 0) { - $(tabmatch[0]).tab('show'); - } - } -}); - -$(function () { - // Code notice - console.log( - 'Hello, curious geek. Our source is at https://github.com/hasgeek. Why not contribute a patch?' - ); -}); - -window.Hasgeek.Forms = { - preventSubmitOnEnter: function (id) { - $('#' + id).on('keyup keypress', function (e) { - var code = e.keyCode || e.which; - if (code === 13) { - e.preventDefault(); - return false; - } - }); - }, - preventDoubleSubmit: function (formId) { - var 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'); - }, - lastuserAutocomplete: function (options) { - var assembleUsers = function (users) { - return users.map(function (user) { - return { id: user.buid, text: user.label }; - }); - }; - - $('#' + options.id).select2({ - placeholder: 'Search for a user', - multiple: options.multiple, - minimumInputLength: 2, - ajax: { - url: options.autocomplete_endpoint, - dataType: 'jsonp', - data: function (params, page) { - if ('client_id' in options) { - return { - q: params.term, - client_id: options.client_id, - session: options.session_id, - }; - } else { - return { - q: params.term, - }; - } - }, - processResults: function (data, page) { - var users = []; - if (data.status == 'ok') { - users = assembleUsers(data.users); - } - return { more: false, results: users }; - }, - }, - }); - }, - textAutocomplete: function (options) { - $('#' + options.id).select2({ - placeholder: 'Type to select', - multiple: options.multiple, - minimumInputLength: 2, - ajax: { - url: options.autocomplete_endpoint, - dataType: 'json', - data: function (params, page) { - return { - q: params.term, - page: page, - }; - }, - processResults: function (data, page) { - return { - more: false, - results: data[options.key].map(function (item) { - return { id: item, text: item }; - }), - }; - }, - }, - }); - }, - /* 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: function (formId, errors) { - var form = document.getElementById(formId); - Object.keys(errors).forEach(function (fieldName) { - if (Array.isArray(errors[fieldName])) { - var fieldWrapper = form.querySelector('#field-' + fieldName); - if (fieldWrapper) { - var 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'); - } - errorElem.innerText = errors[fieldName][0]; - var 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'); - } - } - }); - }, - showFormError: function (formid, error, alertBoxHtml) { - var 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); - } - }, - ajaxFormSubmit: function (formId, url, onSuccess, onError, config) { - $.ajax({ - url: url, - type: 'POST', - data: $('#' + formId).serialize(), - dataType: config.dataType ? config.dataType : 'json', - beforeSend: function () { - window.Hasgeek.Forms.preventDoubleSubmit(formId); - if (config.beforeSend) config.beforeSend(); - }, - success: function (responseData) { - onSuccess(responseData); - }, - error: function (xhr, status, errMsg) { - onError(xhr); - }, - }); - }, - /* 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: function (formId, url, onSuccess, onError, config) { - $('#' + formId) - .find('button[type="submit"]') - .click(function (event) { - event.preventDefault(); - window.Hasgeek.Forms.ajaxFormSubmit(formId, url, onSuccess, onError, config); - }); - }, -}; - -window.Hasgeek.MapMarker = function (field) { - this.field = field; - this.activate(); - return this; -}; - -window.Hasgeek.MapMarker.prototype.activate = function () { - var self = this; - Hasgeek.Forms.preventSubmitOnEnter(this.field.location_id); - - // locationpicker.jquery.js - $('#' + this.field.map_id).locationpicker({ - location: self.getDefaultLocation(), - radius: 0, - zoom: 18, - inputBinding: { - latitudeInput: $('#' + this.field.latitude_id), - longitudeInput: $('#' + this.field.longitude_id), - locationNameInput: $('#' + this.field.location_id), - }, - enableAutocomplete: true, - onchanged: function (currentLocation, radius, isMarkerDropped) { - if ($('#' + self.field.location_id).val()) { - $('#' + self.field.map_id).removeClass('mui--hide'); - } - }, - onlocationnotfound: function (locationName) {}, - oninitialized: function (component) { - // Locationpicker sets latitude and longitude field value to 0, - // this is to empty the fields and hide the map - if (!$('#' + self.field.location_id).val()) { - $('#' + self.field.latitude_id).val(''); - $('#' + self.field.longitude_id).val(''); - $('#' + self.field.map_id).addClass('mui--hide'); - } - }, - }); - - // On clicking clear, empty latitude, longitude, location fields and hide map - $('#' + this.field.clear_id).on('click', function (event) { - event.preventDefault(); - $('#' + self.field.latitude_id).val(''); - $('#' + self.field.longitude_id).val(''); - $('#' + self.field.location_id).val(''); - $('#' + self.field.map_id).addClass('mui--hide'); - }); -}; - -window.Hasgeek.MapMarker.prototype.getDefaultLocation = function () { - var latitude, longitude; - latitude = $('#' + this.field.latitude_id).val(); - longitude = $('#' + this.field.longitude_id).val(); - return { latitude: latitude, longitude: longitude }; -}; - -window.Hasgeek.Utils = { - getInitials: function (name) { - if (name) { - var parts = name.split(/\s+/); - var len = parts.length; - if (len > 1) { - return ( - (parts[0] ? parts[0][0] : '') + (parts[len - 1] ? parts[len - 1][0] : '') - ); - } else if (parts) { - return parts[0] ? parts[0][0] : ''; - } - } - return ''; - }, - getAvatarColour: function (name) { - var avatarColorCount = 6; - var initials = this.getInitials(name); - var stringTotal = 0; - if (initials.length) { - stringTotal = initials.charCodeAt(0); - if (initials.length > 1) { - stringTotal += initials.charCodeAt(1); - } - } - return stringTotal % avatarColorCount; - }, -}; - -window.ParsleyConfig = { - errorsWrapper: '
', - errorTemplate: '

', - errorClass: 'has-error', - classHandler: function (ParsleyField) { - return ParsleyField.$element.closest('.mui-form__fields'); - }, - errorsContainer: function (ParsleyField) { - return ParsleyField.$element.closest('.mui-form__controls'); - }, - i18n: { - en: {}, - }, -}; - -$(function () { - // Override Parsley.js's default messages after the page loads. - // Our versions don't use full stops after phrases. - 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', - } - ); - - var csrfRefresh = function () { - $.ajax({ - type: 'GET', - url: '/api/baseframe/1/csrf/refresh', - timeout: 5000, - dataType: 'json', - success: function (data) { - $('meta[name="csrf-token"]').attr('content', data.csrf_token); - $('input[name="csrf_token"]').val(data.csrf_token); - }, - }); - }; - - //Request for new CSRF token and update the page every 15 mins - setInterval(csrfRefresh, 900000); - - $('body').on('click', '.alert__close', function () { - $(this).parents('.alert').fadeOut(); - }); - - activateZoomPopup(); - addFocusOnModalShow(); -}); diff --git a/funnel/static/translations/en_IN/baseframe.json b/funnel/static/translations/en_IN/baseframe.json new file mode 100644 index 000000000..0c53ce0d0 --- /dev/null +++ b/funnel/static/translations/en_IN/baseframe.json @@ -0,0 +1,12 @@ +{ + "domain": "baseframe", + "locale_data": { + "messages": { + "": { + "domain": "baseframe", + "plural_forms": "nplurals=2; plural=(n != 1);", + "lang": "en" + } + } + } +} diff --git a/funnel/static/translations/en_IN/messages.json b/funnel/static/translations/en_IN/messages.json new file mode 100644 index 000000000..1cc9a42af --- /dev/null +++ b/funnel/static/translations/en_IN/messages.json @@ -0,0 +1,12 @@ +{ + "domain": "messages", + "locale_data": { + "messages": { + "": { + "domain": "messages", + "plural_forms": "nplurals=2; plural=(n != 1);", + "lang": "en" + } + } + } +} diff --git a/funnel/static/translations/hi_IN/baseframe.json b/funnel/static/translations/hi_IN/baseframe.json index 3a1822a2c..d8675d599 100644 --- a/funnel/static/translations/hi_IN/baseframe.json +++ b/funnel/static/translations/hi_IN/baseframe.json @@ -1,9 +1,9 @@ { - "domain": "messages", + "domain": "baseframe", "locale_data": { "messages": { "": { - "domain": "messages", + "domain": "baseframe", "plural_forms": "nplurals=2; plural=(n != 1)", "lang": "hi_IN" }, diff --git a/funnel/static/translations/hi_IN/messages.json b/funnel/static/translations/hi_IN/messages.json index 4f81516b3..26649bdb2 100644 --- a/funnel/static/translations/hi_IN/messages.json +++ b/funnel/static/translations/hi_IN/messages.json @@ -71,18 +71,14 @@ "Not logged in": ["लॉगिन नहीं है"], "This is required": ["यह आवश्यक है"], "This is too long": ["यह काफी लंबा है"], - "Usernames can only have alphabets, numbers and dashes (except at the ends)": [ - "यूजरनेम में सिर्फ वर्णमाला, नंबर और डैश (अंत में छोड़कर) हो सकते हैं" - ], + "Usernames can only have alphabets, numbers and underscores": [""], "This username is reserved": ["यह यूजरनेम रिज़र्व है"], "This username has been taken": ["यह यूजरनेम लिया जा चुका है"], "This username is not available": ["यह यूजरनेम उपलब्ध नहीं है"], "Full name": ["शुभ नाम"], "This is your name, not of your organization": [""], "Username": ["यूजरनेम"], - "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": [""], "Timezone": ["समय क्षेत्र"], "Where in the world are you? Dates and times will be shown in your local timezone": [ "आप फिलहाल कहां पर हैं? समय और तारीख आपको अपने स्थानीय समय क्षेत्र में दिखेंगे" @@ -103,10 +99,6 @@ "इस ईमेल पते की पुष्टि अभी बाकी है" ], "Email address": ["ईमेल पता"], - "Type": ["प्रकार"], - "Home": ["मुखपृष्ठ"], - "Work": ["व्यवसाय"], - "Other": ["अन्य"], "Phone number": ["फोन नंबर"], "Mobile numbers only, in Indian or international format": [ "केवल मोबाइल नंबर, भारतीय या अंतर्राष्ट्रीय फॉर्मेट में" @@ -115,18 +107,6 @@ "Unsubscribe anytime, and control what notifications are sent from the Notifications tab under account settings": [ "कभी भी सदस्यता समाप्त करें, और कौन सी नोटिफिकेशन भेजी जाती हैं, इसे खाता सेटिंग के तहत नोटिफिकेशन टैब से नियंत्रित करें" ], - "This phone number cannot receive SMS messages": [ - "यह फोन नंबर SMS मैसेज प्राप्त नहीं कर सकता है" - ], - "This does not appear to be a valid phone number": [ - "यह एक मान्य फोन नंबर प्रतीत नहीं होता है" - ], - "You have already registered this phone number": [ - "आपने इस फोन नंबर को पहले ही रजिस्टर कर लिया है" - ], - "This phone number has already been claimed": [ - "यह फोन नंबर पहले से ही इस्तेमाल में है" - ], "Report type": ["रिपोर्ट टाइप"], "Application title": ["ऐप्लिकेशन का शीर्षक"], "The name of your application": ["आपके ऐप्लिकेशन का नाम"], @@ -175,9 +155,7 @@ "Select a team to assign permissions to": ["अनुमति देने के लिए टीम का चयन करें"], "Unknown team": ["अज्ञात टीम"], "Get notifications": [""], - "This email address has been claimed by someone else": [ - "इस ईमेल पते का इस्तेमाल किसी और के द्वारा किया जा चुका है" - ], + "This email address is linked to another account": [""], "This email address is already registered. You may want to try logging in or resetting your password": [ "यह ईमेल पता पहले से रजिस्टर है. आपको लॉगिन करनी चाहिए या फिर अपने पासवर्ड को रीसेट करनी चाहिए" ], @@ -196,13 +174,29 @@ "This email address is no longer valid. If you believe this to be incorrect, email {support} asking for the address to be activated": [ "यह ईमेल पता अब वैध नहीं है. अगर फिर भी आपको ये गलत लगता है, तो इस पते को सक्रिय करने के लिए {support} को ईमेल भेजें" ], + "This email address has been blocked from use": [""], "You or someone else has made an account with this email address but has not confirmed it. Do you need to reset your password?": [ "आपने या किसी और ने इसे ईमेल पते से एक अकाउंट बनाया था, पर उसकी पुष्टि नहीं की है. क्या आप अपना पासवर्ड रीसेट करना चाहेंगे?" ], + "This phone number cannot receive SMS messages": [ + "यह फोन नंबर SMS मैसेज प्राप्त नहीं कर सकता है" + ], + "This does not appear to be a valid phone number": [ + "यह एक मान्य फोन नंबर प्रतीत नहीं होता है" + ], + "This phone number is linked to another account": [""], + "This phone number is already registered. You may want to try logging in or resetting your password": [ + "" + ], + "You have already registered this phone number": [ + "आपने इस फोन नंबर को पहले ही रजिस्टर कर लिया है" + ], + "This phone number has been blocked from use": [""], "A https:// URL is required": ["https:// URL आवश्यक है"], "Images must be hosted at images.hasgeek.com": [ "तस्वीरें images.hasgeek.com पर होस्ट की जानी चाहिए" ], + "This video URL is not supported": [""], "Label": ["लेबल"], "This can’t be empty": ["इसे खाली न छोड़ें"], "Make this label mandatory in submission forms": [""], @@ -214,7 +208,6 @@ "अगर चुना गया, तो केवल संपादक और समीक्षक ही प्रस्ताव में इस लेबल का उपयोग कर सकते हैं" ], "Option": ["विकल्प"], - "This email address has been blocked from use": [""], "This account could not be identified. Try with a phone number or email address": [ "" ], @@ -257,6 +250,9 @@ "Can check-in a participant using their badge at a physical event": [ "किसी वास्तविक इवेंट में प्रतिभागियों के बैच की जांच करके अंदर आने की अनुमति दे सकते हैं" ], + "Role": [""], + "Optional – Name this person’s role": [""], + "Select one or more roles": [""], "Choice": ["विकल्प"], "Accept": ["स्वीकारें"], "Decline": ["अस्वीकारें"], @@ -342,6 +338,15 @@ "Disabled this WhatsApp notification": [ "यह WhatsApp नोटिफिकेशन अक्षम कर दिया गया" ], + "Signal": [""], + "To enable, add your Signal number": [""], + "Notify me on Signal (beta)": [""], + "Uncheck this to disable all Signal notifications": [""], + "Signal notifications": [""], + "Enabled selected Signal notifications": [""], + "Enabled this Signal notification": [""], + "Disabled all Signal notifications": [""], + "Disabled this Signal notification": [""], "Notify me": ["सूचित करें"], "Uncheck this to disable all notifications": [ "सभी नोटिफिकेशन अक्षम करने के लिए इसे चिन्हित से हटाएं" @@ -359,12 +364,10 @@ "Your organization’s given name, without legal suffixes such as Pvt Ltd": [ "आपके संगठन का नाम, बगैर किसी प्रकार के जैसे कि Pvt Ltd (प्र. लि.)" ], - "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": [ - "आपके संगठन के अकाउंट पेज के लिए एक संक्षिप्त नाम. एक शब्द जिसमें केवल अक्षर, नंबर और डैश ही शामिल हों. कुछ स्थाई सा नाम रखें: इसे बदलने से वेब पर मौजूद पुराने लिंक काम नहीं करेंगे" - ], - "Names can only have letters, numbers and dashes (except at the ends)": [ - "नाम में केवल अक्षर, नंबर और डैश (अंत में छोड़कर) हो सकते हैं" + "A unique word for your organization’s account page. Alphabets, numbers and underscores are okay. Pick something permanent: changing it will break links": [ + "" ], + "Names can only have alphabets, numbers and underscores": [""], "This name is reserved": ["यह नाम रिज़र्व है"], "This is your current username. You must change it first from your account before you can assign it to an organization": [ "यह आपका मौजूदा यूजरनेम है. इसे किसी संगठन को सौंपने से पहले आपको अपने अकाउंट से बदलना होगा" @@ -391,8 +394,8 @@ "आपका पूरा नाम, जिससे कि दूसरे व्यक्ति आपको जान सकें" ], "A brief statement about yourself": [""], - "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": [ - "@username और आपके अकाउंट पेज की URL के साथ लगाने के लिए एक उपनाम. एक शब्द जिसमें केवल अक्षर, नंबर और डैश मौजूद हों. कुछ स्थाई सा नाम रखें: इसे बदलने से वेब पर मौजूद पुराने लिंक काम नहीं करेंगे" + "A single word that is uniquely yours, for your account page and @mentions. Pick something permanent: changing it will break existing links": [ + "" ], "More about you": [""], "Account visibility": [""], @@ -456,7 +459,6 @@ "Content": ["विषय सूची"], "Video": ["वीडियो"], "YouTube or Vimeo URL (optional)": ["YouTube या Vimeo URL (ऐच्छिक)"], - "Role": [""], "Optional – A specific role in this submission (like Author or Editor)": [""], "Hide collaborator on submission": [""], "{user} is already a collaborator": [""], @@ -482,7 +484,12 @@ "Item collection id": [""], "Allow rsvp": [""], "If checked, both free and buy tickets will shown on project": [""], + "This is a subscription": [""], + "If not checked, buy tickets button will be shown": [""], + "Register button text": [""], + "Optional – Use with care to replace the button text": [""], "Badge template URL": ["बैच टेम्पलेट URL"], + "URL of background image for the badge": [""], "Name": ["नाम"], "Client id": ["क्लाइंट आईडी"], "Client event id": ["क्लाइंट ईवेंट आईडी"], @@ -521,7 +528,7 @@ "You denied the GitHub login request": [ "आपने GitHub लॉग इन अनुरोध को ख़ारिज कर दिया है" ], - "This server's callback URL is misconfigured": [ + "This server’s callback URL is misconfigured": [ "सर्वर की कॉलबैक URL की विन्यास गलत है" ], "Unknown failure": ["अज्ञात समस्या"], @@ -575,7 +582,7 @@ "My subscriptions and billing": ["मेरे सदस्यता और बिल"], "Projects I am participating in": ["मेरे द्वारा भाग लिए प्रोजेक्ट"], "Projects I am a crew member in": ["प्रोजेक्ट, जिनमें मैं दल का हिस्सा हूं"], - "Organizations I manage": ["मेरे द्वारा प्रबंधित संगठन"], + "Accounts I manage": [""], "As a website administrator": ["वेबसाइट एडमिनिस्ट्रेटर के तौर पर"], "Queued": ["श्रेणीबद्ध"], "Pending": ["लंबित"], @@ -591,48 +598,34 @@ "This will prompt a calendar entry in Gmail and other apps": [ "यह Gmail तथा अन्य एप्स में कैलेंडर एंट्री के लिए सूचना देगा" ], - "When I cancel my registration": ["जब मैं अपना पंजीकरण रद्द करूं"], - "Confirmation for your records": ["आपके रिकॉर्ड के लिए पुष्टि"], "When a project posts an update": ["जब किसी प्रोजेक्ट की कुछ खबर दी जाए"], "Typically contains critical information such as video conference links": [ "खासतौर से महत्वपूर्ण जानकारी होते हैं जैसे कि वीडियो कॉन्फ्रेंस के लिंक" ], "When I submit a proposal": ["जब मैं कोई प्रस्ताव भेजूं"], + "Confirmation for your records": ["आपके रिकॉर्ड के लिए पुष्टि"], "When a project I’ve registered for is about to start": [ "जब मेरे द्वारा पंजीकृत कोई प्रोजेक्ट शुरू होने वाला हो" ], "You will be notified 5-10 minutes before the starting time": [ "आपको शुरू होने के 5-10 मिनट पहले सूचित किया जाएगा" ], - "When there is a new comment on a project or proposal I’m in": [ - "जब मेरे द्वारा शामिल किसी प्रोजेक्ट या प्रस्ताव में कोई नई कमेंट की जाए" - ], - "When someone replies to my comment": ["जब कोई मेरे मेरे कमेंट का जवाब दे"], - "When a project crew member is added, or roles change": [ - "जब प्रोजेक्ट के दल में किसी सदस्य को जोड़ा जाए या भूमिका बदली जाए" - ], - "Crew members have access to the project’s controls": [ - "दल के सदस्य के पास प्रोजेक्ट के नियंत्रणों को बदलने का एक्सेस होता है" - ], - "When a project crew member is removed, including me": [ - "मेरे सहित, जब प्रोजेक्ट के दल के किसी सदस्य को हटाया जाए" - ], + "When there is a new comment on something I’m involved in": [""], + "When someone replies to my comment or mentions me": [""], + "When a project crew member is added or removed": [""], + "Crew members have access to the project’s settings and data": [""], "When my project receives a new proposal": [ "जब मेरे प्रोजेक्ट में कोई नया प्रस्ताव आए" ], "When someone registers for my project": [ "जब कोई मेरे प्रोजेक्ट के लिए पंजीकृत हो" ], - "When organization admins change": ["जब संगठन का कोई एडमिन बदले"], - "Organization admins control all projects under the organization": [ - "संगठन के एडमिन उसमें मौजूद सभी प्रोजेक्ट का नियंत्रण रखते हैं" - ], - "When an organization admin is removed, including me": [ - "मेरे सहित, जब संगठन के किसी एडमिन को हटाया जाए" - ], + "When account admins change": [""], + "Account admins control all projects under the account": [""], "When a comment is reported as spam": [ "जब किसी कमेंट को स्पैम जैसा रिपोर्ट किया जाए" ], + "[blocked]": [""], "Autogenerated": ["स्वचालित"], "Public": ["सार्वजनिक"], "Private": ["निजी"], @@ -689,7 +682,7 @@ "Cancel": ["रद्द करें"], "This proposal has been cancelled": ["इस प्रस्ताव को रद्द कर दिया गया"], "Undo cancel": ["रद्द किए को स्वीकारें"], - "This proposal's cancellation has been reversed": [ + "This proposal’s cancellation has been reversed": [ "इस प्रस्ताव को रद्द किए से स्वीकारा जा चुका है" ], "Awaiting details for this proposal": ["इस प्रस्ताव की जानकारी का इंतज़ार है"], @@ -709,7 +702,10 @@ "Suspended": ["निलंबित"], "Merged": ["संयोजित"], "Invited": ["आमंत्रित"], - "This must be a shareable URL for a single file in Google Drive": [""], + "timeFormat": [""], + "dragOpacity": [""], + "dragRevertDuration": [""], + "defaultEventMinutes": [""], "${}": [""], ".": [""], "@": [""], @@ -734,7 +730,7 @@ "" ], "About Hasgeek": ["Hasgeek का परिचय"], - "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.": [ "" ], "Find your peers": ["अपने साथियों को ढूंढें"], @@ -749,7 +745,9 @@ "Subscribe to your favourite communities and make sure you never miss a conversation or an opportunity to collaborate.": [ "अपने पसंदीदा समुदायों की सदस्यता लें और किसी भी संवाद या संग काम करने के अवसर को हाथ से जाने न दें." ], + "Add username": [""], "Go to account": ["अकाउंट पर जाएँ"], + "Info": [""], "(none)": ["(कोई भी नहीं)"], "From device": ["इस डिवाइस से"], "Edit": ["संपादित करें"], @@ -763,6 +761,7 @@ "Email addresses": ["ईमेल पते"], "Primary": ["प्राथमिक"], "(pending verification)": ["(वेरीफिकेशन बाकी है)"], + "Set as primary email": [""], "Set as primary": ["प्राथमिक बनाएं"], "Add an email address": ["अन्य ईमेल पता जोड़ें"], "Mobile numbers": ["मोबाइल नंबर"], @@ -773,6 +772,7 @@ "%(since)s से स्थापित – आखरी बार इस्तेमाल %(last_used)s" ], "Since %(since)s": ["%(since)s से स्थापित"], + "Disconnect": ["डिसकनेक्ट करें"], "Login sessions": ["लॉगिन सेशन"], "%(browser)s on %(device)s": [""], "Since %(since)s via %(login_service)s – last active %(last_active)s": [ @@ -788,7 +788,6 @@ "Cookies are required to login. Please enable cookies in your browser’s settings and reload this page": [ "लॉगिन के लिए कुकीज़ की आवश्यक हैं. कृपया अपने ब्राउज़र के सेटिंग्स में जाकर कुकीज़ को सक्षम करें और इस पेज को फिर से लोड करें" ], - "Add username": [""], "Organizations": ["संगठन"], "%(count)s more": [""], "Account settings": [""], @@ -823,7 +822,6 @@ "Your access token": ["आपका एक्सेस टोकन"], "Created": ["निर्मित"], "Last used": ["अंतिम उपयोग"], - "Disconnect": ["डिसकनेक्ट करें"], "Access keys": ["एक्सेस कीज़"], "Purpose": ["उद्देश्य"], "Key": ["की (कुंजी)"], @@ -859,6 +857,7 @@ ], "Error": ["त्रुटी"], "Error URI": ["त्रुटी URI"], + "Badge": [""], "Visible": [""], "Collaborator menu": [""], "Add collaborator": [""], @@ -869,6 +868,7 @@ "Download CSV": ["CSV डाउनलोड करें"], "CSV": ["CSV"], "Download contacts CSV": ["कॉन्टैक्ट की CSV फाइल डाउनलोड करें"], + "Download contact": [""], "This form timed out because it was open for a long time. Please submit again": [ "इस फॉर्म की समय सीमा समाप्त हो गई क्योंकि यह काफी देर से खुली हुई थी. कृपया दोबारा जमा करें" ], @@ -885,20 +885,15 @@ "Confirm your email address": ["अपने ईमेल पते की पुष्टि करें"], "Hello!": [""], "This login OTP is valid for 15 minutes.": [""], - "\n %(actor)s has added you to ‘%(project)s’ as a crew member.\n ": [ - "\n %(actor)s ने आपको ‘%(project)s’ दल के सदस्य के रूप में जोड़ा है.\n " - ], - "See all crew members": ["दल के सभी सदस्यों को देखें"], - "\n %(actor)s has invited you to join ‘%(project)s’ as a crew member.\n ": [ - "\n %(actor)s ने आपको दल के सदस्य के रूप में ‘%(project)s’ में शामिल होने के लिए आमंत्रित किया है.\n " - ], - "Accept or decline invite": ["आमंत्रण को स्वीकार करें या अस्वीकारें"], - "\n %(actor)s has removed you as a crew member from ‘%(project)s’.\n ": [ - "\n %(actor)s ने आपको ‘%(project)s’ के दल के सदस्य से हटा दिया है.\n " - ], "You are about to perform a critical action. This OTP serves as your confirmation to proceed and is valid for 15 minutes.": [ "" ], + "Enter a location": [""], + "Clear location": [""], + "switch to alphabet keyboard": [""], + "switch to numeric keyboard": [""], + "Show password": [""], + "Hide password": [""], "This form has timed out. Please submit again to confirm": [ "इस फॉर्म की समय सीमा समाप्त हो चुकी है. पुष्टि के लिए कृपया दोबारा से जमा करें" ], @@ -908,6 +903,7 @@ "Spotlight:": ["सुर्खियां:"], "What’s this about?": [""], "Explore communities": [""], + "Label badge": [""], "Manage labels": ["लेबल संपादित करें"], "Create new label": ["नया लेबल बनाएं"], "(No labels)": ["(कोई लेबल नहीं है)"], @@ -916,8 +912,10 @@ "Please review the indicated issues": [ "कृपया दर्शाए गए समस्याओं की समीक्षा करें" ], + "Add option": [""], "Done": [""], - "Search the site": ["साइट पर खोजें"], + "Home": ["मुखपृष्ठ"], + "Search this site": [""], "Search…": ["खोजें…"], "Search": ["खोजें"], "Updates": ["अपडेट"], @@ -926,6 +924,7 @@ "Login": ["लॉगिन"], "Tell us where you’d like to get updates. We’ll send an OTP to confirm.": [""], "Or, use your existing account, no OTP required": [""], + "Login beacon": [""], "Logging out…": ["लॉग आउट हो रहा है…"], "Login to save this project": [""], "Save this project": [""], @@ -933,6 +932,7 @@ "Copy link": ["लिंक कॉपी करें"], "Facebook": ["Facebook"], "Preview video": ["प्रीव्यू वीडियो"], + "Powered by VideoKen": [""], "Edit submission video": ["सबमिशन से जुड़ा वीडियो संपादित करें"], "Edit session video": ["सेशन का वीडियो संपादित करें"], "Share": ["साझा करें"], @@ -951,40 +951,6 @@ "Accepting submissions": ["सब्मिशन स्वीकार कर रहे हैं"], "Show more": ["और दिखाएं"], "All projects": ["सभी प्रोजेक्ट"], - "Manage admins": ["एडमिंस को प्रबंधित करें"], - "View admins": ["एडमिंस को देखें"], - "Edit this account": ["इस अकाउंट को संपादित करें"], - "Make account private": ["अकाउंट को निजी बनाएं"], - "Make this account private?": ["वाकई इस अकाउंट को निजी बनाना चाहते हैं?"], - "Your account will not be visible to anyone other than you": [ - "आपका अकाउंट आपके अलावा किसी और को नहीं दिखाई देगा" - ], - "It will not be listed in search results": [ - "यह खोज परिणामों में भी दिखाई नहीं देगा" - ], - "You cannot host projects from this account": [ - "आप इस अकाउंट से प्रोजेक्ट होस्ट नहीं कर सकते" - ], - "Any existing projects will become inaccessible until the account is public again": [ - "जब तक कि इस अकाउंट को दोबारा से सार्वजनिक नहीं बनाया जाता तब तक कोई भी मौजूदा प्रोजेक्ट इसके द्वारा एक्सेस नहीं किया जा सकता" - ], - "Back to the account": ["अकाउंट में वापस जाएं"], - "Add cover photo url": ["कवर फोटो का URL जोड़ें"], - "Add cover photo": ["कवर फोटो जोड़ें"], - "New project": ["नया प्रोजेक्ट"], - "Make account public": ["अकाउंट को सार्वजनिक बनाएं"], - "Make this account public?": ["इस अकाउंट को सार्वजनिक बनाएं?"], - "Your account will be visible to anyone visiting the page": [ - "आपका अकाउंट इस पेज पर आने वाले सभी व्यक्तियों को दिखाई देगा" - ], - "Your account will be listed in search results": [ - "आपका अकाउंट खोज प्राणिमों में दिखाई देगा" - ], - "Joined %(date)s": ["%(date)s को जुड़े"], - "Admins": ["एडमिन"], - "Sessions": ["सेशन"], - "Projects": ["प्रोजेक्ट"], - "Submissions": ["सबमिशन"], "Team & careers": ["टीम & करियर"], "Contact": ["संपर्क"], "Site policies": ["साइट नीतियां"], @@ -992,6 +958,7 @@ "Supported by": [""], "Video thumbnail": [""], "more": [""], + "%(count)s comment": [""], "This proposal has a preview video": [ "इस प्रस्ताव के साथ एक प्रीव्यू वीडियो मौजूद है" ], @@ -1039,6 +1006,7 @@ "Search Hasgeek for projects, discussions and more": [ "प्रोजेक्ट, चर्चा तथा और भी चीज़ों को Hasgeek पर खोजें" ], + "Admins": ["एडमिन"], "Teams": ["टीम"], "New team": ["नया टीम"], "Linked apps": ["जुड़े हुए ऐप्स"], @@ -1057,7 +1025,41 @@ "Write, collaborate and get feedback": [ "लिखें, दूसरे से जुड़ें और प्रतिक्रिया पाएं" ], + "New project": ["नया प्रोजेक्ट"], "Unscheduled projects": ["अनिर्धारित प्रोजेक्ट"], + "Manage admins": ["एडमिंस को प्रबंधित करें"], + "View admins": ["एडमिंस को देखें"], + "Edit this account": ["इस अकाउंट को संपादित करें"], + "Make account private": ["अकाउंट को निजी बनाएं"], + "Make this account private?": ["वाकई इस अकाउंट को निजी बनाना चाहते हैं?"], + "Your account will not be visible to anyone other than you": [ + "आपका अकाउंट आपके अलावा किसी और को नहीं दिखाई देगा" + ], + "It will not be listed in search results": [ + "यह खोज परिणामों में भी दिखाई नहीं देगा" + ], + "You cannot host projects from this account": [ + "आप इस अकाउंट से प्रोजेक्ट होस्ट नहीं कर सकते" + ], + "Any existing projects will become inaccessible until the account is public again": [ + "जब तक कि इस अकाउंट को दोबारा से सार्वजनिक नहीं बनाया जाता तब तक कोई भी मौजूदा प्रोजेक्ट इसके द्वारा एक्सेस नहीं किया जा सकता" + ], + "Back to the account": ["अकाउंट में वापस जाएं"], + "Add cover photo url": ["कवर फोटो का URL जोड़ें"], + "Add cover photo": ["कवर फोटो जोड़ें"], + "Update logo": [""], + "Make account public": ["अकाउंट को सार्वजनिक बनाएं"], + "Make this account public?": ["इस अकाउंट को सार्वजनिक बनाएं?"], + "Your account will be visible to anyone visiting the page": [ + "आपका अकाउंट इस पेज पर आने वाले सभी व्यक्तियों को दिखाई देगा" + ], + "Your account will be listed in search results": [ + "आपका अकाउंट खोज प्राणिमों में दिखाई देगा" + ], + "Joined %(date)s": ["%(date)s को जुड़े"], + "Sessions": ["सेशन"], + "Projects": ["प्रोजेक्ट"], + "Submissions": ["सबमिशन"], "Back to the home": [""], "Featured submissions": [""], "See all": ["सारे देखें"], @@ -1088,45 +1090,51 @@ ], "Update livestream URLs": ["लाइवस्ट्रीम URLs को बदलें"], "Update banner image": ["बैनर इमेज को बदलें"], - "Free updates": [""], - "Free": [""], + "This is a free event": [""], "Register": ["रजिस्टर करें"], "This will share your name and email address with the project’s promoter so they can keep you updated. You can cancel your registration at any time": [ "" ], - "Registration menu": [""], - "Stop following": [""], - "Cancel registration": ["रजिस्ट्रेशन रद्द करें"], + "Following": [""], + "Registered": [""], + "Unfollow": [""], + "Cancel Registration": [""], "No longer interested?": [""], "Can’t make it?": ["आप मदद नहीं कर सकते?"], + "Stop following": [""], "Confirm cancellation": ["रद्द करने की पुष्टि करें"], - "View participants": ["प्रतिभागियों को देखें"], - "Get Tickets": [""], - "Tickets available": [""], - "Sales closed": [""], - "More options": [""], + "Like this? Support the community": [""], "Get tickets": ["टिकट पाएं"], + "Sales closed": [""], "Share this project": [""], + "Add to calendar": [""], + "Site editor menu": [""], + "Featured projects appear under Spotlight on the home page": [""], + "Add sponsor": [""], "Menu": [""], "Edit URL": [""], "Edit description": [""], "Settings": ["सेटिंग्स"], "Update venue": ["ईवेंट का स्थान अपडेट करें"], - "Site editor menu": [""], - "Featured projects appear under Spotlight on the home page": [""], - "Add sponsor": [""], + "View participants": ["प्रतिभागियों को देखें"], "This project is not published. Visit settings to publish": [""], "Related events": ["सम्बंधित ईवेंट"], "Add schedule": [""], "Hosted by": [""], + "Edit sponsor": [""], + "Remove sponsor": [""], "Promoted": [""], "Project overview": [""], "Jobs": ["नौकरियां"], "Crew": ["दल"], + "Email/Phone": [""], + "Responded at": [""], "(Unverified)": [""], + "No users": [""], "%(title)s by %(speaker)s, %(project)s": [ "%(speaker)s, %(project)s के द्वारा ‘%(title)s’" ], + "%(title)s, %(project)s": [""], "Edit schedule": ["कार्यक्रम को संपादित करें"], "Project status: %(status)s": [""], "Edit guidelines and timing": [""], @@ -1148,6 +1156,7 @@ "Badge scanner": [""], "Scan participant badges at in-person events": [""], "expand": [""], + "Search titles…": [""], "Add new update": ["नई जानकारी डालें"], "Post an update…": ["नई जानकारी पोस्ट करें…"], "This project has not posted any updates yet": [ @@ -1155,8 +1164,8 @@ ], "No videos": [""], "Next": ["आगे"], - "Tomorrow ": [""], "Select date": ["डेट चुनें"], + "yyyy-mm-dd": [""], "Select a date to add sessions": ["सेशन जोड़ने के लिए डेट चुनें"], "Unscheduled submissions": [""], "Revert": ["वापस करें"], @@ -1164,6 +1173,9 @@ "Subscribe to the schedule": ["कार्यक्रम को सूचीपत्र में जोड़ें"], "Download": ["डाउनलोड करें"], "Add to Google Calendar": ["Google Calendar में जोड़ें"], + "in": [""], + "at": [""], + "on": [""], "edited %(date)s": [""], "This session has not been scheduled yet": [ "इस सेशन की समय-सूची अभी नहीं बनाई गई है" @@ -1173,21 +1185,21 @@ "Add session video": ["सेशन की वीडियो जोड़ें"], "View submission for this session": [""], "Query": ["सवाल"], - "\n Only %(total_comments)s comment for \"%(query)s\"": [ - "\n “%(query)s” के लिए केवल %(total_comments)s कमेंट" - ], - "\n Only %(total_comments)s comment": [ - "\n केवल %(total_comments)s कमेंट" - ], + "Only %(count)s comment for “%(query)s”": [""], + "Only %(count)s comment": [""], "Comment": ["कमेंट"], + "Link": [""], "No comment available": ["कोई कमेंट मौजूद नहीं है"], + "Report spam": [""], + "Site admin": [""], "Review comments": ["कमेंट की समीक्षा करें"], "This comment has been reported as spam": [ "यह कमेंट पैम के तौर पर रिपोर्ट की गई है" ], + "Unsubscribe": [""], + "Subscribe": [""], "Stop notifications": [""], "Edit submission": [""], - "Add proposal video": ["प्रस्ताव का वीडियो जोड़ें"], "Replace video": [""], "Add video": ["वीडियो जोड़ें"], "Editor panel": [""], @@ -1198,22 +1210,26 @@ "This submission has been added to the schedule": [""], "No video": ["कोई वीडियो नहीं है"], "Session video": ["सेशन वीडियो"], + "Play video": [""], "Submission video": [""], "Submitted %(date)s ": ["%(date)s को सबमिट "], "Change status of proposal": ["प्रस्ताव की स्थिति बदलें"], "Move": ["दूसरी जगह भेजें"], "Back to edit proposal": [""], - "Markdown": [""], "Preview": [""], "Add labels to submission": [""], - "Select a relevant label": [""], + "This value is required": [""], "Add video url": [""], - "Link a video": [""], "Collaborators": [""], "Manage collaborators": [""], "Add new collaborator": [""], + "Markdown": [""], "Setup events": ["ईवेंट सेटअप करें"], "Back to setup events": ["ईवेंट सेटअप पर वापस जाएं"], + "Badges to be printed": [""], + "Label badges to be printed": [""], + "Badges printed": [""], + "Actions": [""], "Manage check-in": ["चेक-इन प्रबंधित करें"], "Select an event to check-in attendees": [ "प्रतिभागियों को चेक-इन करने के लिए एक ईवेंट चुनें" @@ -1324,6 +1340,7 @@ "Update #%(number)s": ["अपडेट #%(number)s"], "Posted by %(editor)s %(age)s": ["%(age)s %(editor)s के द्वारा"], "See all admins": ["सभी एडमिन को देखें"], + "See all crew members": ["दल के सभी सदस्यों को देखें"], "%(project)s starts at %(start_time)s": [ "%(project)s शुरू होने का समय %(start_time)s" ], @@ -1344,10 +1361,8 @@ "Your project %(project)s has received a new submission: %(proposal)s from %(actor)s": [ "आपके %(project)s प्रोजेक्ट में एक नया सबमिशन आया है: %(proposal)s %(actor)s की ओर से" ], - "You have submitted a new proposal %(proposal)s to the project %(project)s": [ - "आपने %(project)s प्रोजेक्ट में एक नया प्रस्ताव %(proposal)s भेजा है" - ], - "View proposal": ["प्रस्ताव देखें"], + "You have submitted %(proposal)s to the project %(project)s": [""], + "View submission": [""], "You submitted %(proposal)s to %(project)s": [ "आपने %(project)s प्रोजेक्ट में %(proposal)s जमा किया है" ], @@ -1380,6 +1395,9 @@ "Reset password": ["पासवर्ड रिसेट करें"], "Contact support": ["सहयोगी दल से संपर्क करें"], "Your password has been updated": ["आपके पासवर्ड को बदल दिया गया है"], + "This phone number is not available": [""], + "This phone number has been blocked": ["यह फोन नंबर ब्लॉक कर दिया गया है"], + "This phone number cannot receive text messages": [""], "Unparseable response from Exotel": ["Exotel की ओर से अजीब प्रतिक्रिया"], "Exotel API error": ["Exotel API त्रुटी"], "Exotel not reachable": ["Exotel पहुंच के बाहर है"], @@ -1387,7 +1405,6 @@ "Hasgeek cannot send messages to phone numbers in this country.Please contact support via email at {email} if this affects youruse of the site": [ "" ], - "This phone number has been blocked": ["यह फोन नंबर ब्लॉक कर दिया गया है"], "This phone number is unsupported at this time": [ "यह फोन नंबर इस समय असमर्थित है" ], @@ -1619,15 +1636,6 @@ "Verify your email address": ["अपना ईमेल पता वेरिफाई करें"], "Verify email address": ["ईमेल पता वेरिफाई करें"], "Reset your password - OTP {otp}": [""], - "You have been added to {project} as a crew member": [ - "आपको दल के सदस्य के रूप में {project} में जोड़ा गया है" - ], - "You have been invited to {project} as a crew member": [ - "आपको दल के सदस्य के रूप में {project} में आमंत्रित किया गया है" - ], - "You have been removed from {project} as a crew member": [ - "आपको दल के सदस्य के रूप में {project} से हटा दिया गया है" - ], "Terms of service": ["सेवा की शर्तें"], "Sponsorship & advertising": ["प्रायोजन और विज्ञापन"], "Privacy policy": ["गोपनीयता नीति"], @@ -1725,6 +1733,7 @@ "The user has been added as a member": [ "यूज़र को सदस्य के रूप में जोड़ दिया गया है" ], + "This is not a valid response": [""], "The member’s record was edited elsewhere. Reload the page": [ "सदस्य का रिकॉर्ड कहीं और संपादित किया गया था. पेज को रिलोड करें" ], @@ -1821,65 +1830,45 @@ "One follower so far": [""], "You are following this": [""], "Two registrations so far": ["अब तक दो रजिस्ट्रेशन"], - "You and one other have registered": [ - "आपने और एक अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & one other have registered": [""], "Two followers so far": [""], - "You and one other are following": [""], + "You & one other are following": [""], "Three registrations so far": ["अब तक तीन रजिस्ट्रेशन"], - "You and two others have registered": [ - "आपने और दो अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & two others have registered": [""], "Three followers so far": [""], - "You and two others are following": [""], + "You & two others are following": [""], "Four registrations so far": ["अब तक चार रजिस्ट्रेशन"], - "You and three others have registered": [ - "आपने और तीन अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & three others have registered": [""], "Four followers so far": [""], - "You and three others are following": [""], + "You & three others are following": [""], "Five registrations so far": ["अब तक पांच रजिस्ट्रेशन"], - "You and four others have registered": [ - "आपने और चार अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & four others have registered": [""], "Five followers so far": [""], - "You and four others are following": [""], + "You & four others are following": [""], "Six registrations so far": ["अब तक छे रजिस्ट्रेशन"], - "You and five others have registered": [ - "आपने और पांच अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & five others have registered": [""], "Six followers so far": [""], - "You and five others are following": [""], + "You & five others are following": [""], "Seven registrations so far": ["अब तक सात रजिस्ट्रेशन"], - "You and six others have registered": [ - "आपने और छे अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & six others have registered": [""], "Seven followers so far": [""], - "You and six others are following": [""], + "You & six others are following": [""], "Eight registrations so far": [""], - "You and seven others have registered": [ - "आपने और सात अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & seven others have registered": [""], "Eight followers so far": [""], - "You and seven others are following": [""], + "You & seven others are following": [""], "Nine registrations so far": ["अब तक नौ रजिस्ट्रेशन"], - "You and eight others have registered": [ - "आपने और आठ अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & eight others have registered": [""], "Nine followers so far": [""], - "You and eight others are following": [""], + "You & eight others are following": [""], "Ten registrations so far": ["अब तक दस रजिस्ट्रेशन"], - "You and nine others have registered": [ - "आपने और नौ अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & nine others have registered": [""], "Ten followers so far": [""], - "You and nine others are following": [""], + "You & nine others are following": [""], "{num} registrations so far": ["अब तक {num} रजिस्ट्रेशन"], - "You and {num} others have registered": [ - "आपने और {num} अन्य व्यक्ति ने रजिस्टर कर लिया है" - ], + "You & {num} others have registered": [""], "{num} followers so far": [""], - "You and {num} others are following": [""], + "You & {num} others are following": [""], "Follow": [""], "Your new project has been created": ["आपका नया प्रोजेक्ट बना दिया गया है"], "Create project": ["प्रोजेक्ट बनाएं"], @@ -1915,6 +1904,7 @@ "Sponsor could not be edited": [""], "Sponsor has been removed": [""], "Sponsor could not be removed": [""], + "Remove sponsor?": [""], "Remove ‘{sponsor}’ as a sponsor?": [""], "This form uses Markdown for formatting": [ "यह फॉर्म फॉर्मेटिंग के लिए Markdown का उपयोग करता है" @@ -1924,6 +1914,7 @@ ], "New submission": [""], "{user} has been added as an collaborator": [""], + "Pick a user to be added": [""], "Delete your submission ‘{title}’? This will remove all comments as well. This operation is permanent and cannot be undone": [ "अपना ‘{title}’ प्रस्ताव मिटाएं? यह सभी कमेंटों को भी हटा देगा. ऐसा करना इसे हमेशा के लिए मिटा देगा और इसे वापस पहले जैसा नहीं किया जा सकता" ], @@ -2037,6 +2028,7 @@ "Something went wrong. Please reload and try again": [ "कुछ गड़बड़ी हुई है. कृपया दोबारा लोड करें और दोबारा प्रयास करें" ], + "Unknown Markdown profile: {profile}": [""], "Full access is only available to trusted clients": [ "पूरा ऐक्सेस केवल ट्रस्टेड क्लाइंट के लिए ही उपलब्ध है" ], @@ -2100,72 +2092,122 @@ "{actor} commented on a project you are in:": [""], "{actor} commented on your submission:": [""], "{actor} replied to you:": [""], - "You have been invited as an owner of {organization} by {actor}": [ - "आपको {actor} द्वारा {organization} के ओनर के रूप में आमंत्रित किया गया है" - ], - "You have been invited as an admin of {organization} by {actor}": [ - "आपको {actor} द्वारा {organization} के एडमिन के रूप में आमंत्रित किया गया है" - ], - "You are now an owner of {organization}": ["अब आप {organization} के ओनर हैं"], - "You are now an admin of {organization}": ["अब आप {organization} के एडमिन हैं"], - "You have changed your role to owner of {organization}": [ - "आपने अपनी भूमिका {organization} के एक ओनर के रूप में बदल दी है" - ], - "You have changed your role to an admin of {organization}": [ - "आपने अपनी भूमिका {organization} के एक एडमिन के रूप में बदल दी है" - ], - "You were added as an owner of {organization} by {actor}": [ - "आपको {actor} द्वारा {organization} के ओनर के रूप में जोड़ा गया था" - ], - "You were added as an admin of {organization} by {actor}": [ - "आपको {actor} द्वारा {organization} के एडमिन के रूप में जोड़ा गया था" - ], - "Your role was changed to owner of {organization} by {actor}": [ - "आपकी भूमिका {actor} द्वारा {organization} के ओनर के रूप में बदल दी गई थी" - ], - "Your role was changed to admin of {organization} by {actor}": [ - "आपकी भूमिका {actor} द्वारा {organization} के एडमिन के रूप में बदल दी गई थी" - ], - "{user} was invited to be an owner of {organization} by {actor}": [ - "{user} को {actor} द्वारा {organization} का ओनर बनने के लिए आमंत्रित किया गया था" - ], - "{user} was invited to be an admin of {organization} by {actor}": [ - "{user} को {actor} द्वारा {organization} का एडमिन बनने के लिए आमंत्रित किया गया था" - ], - "{user} is now an owner of {organization}": [ - "{user} अब {organization} का ओनर है" - ], - "{user} is now an admin of {organization}": [ - "{user} अब {organization} का एडमिन है" - ], - "{user} changed their role to owner of {organization}": [ - "{user} ने अपनी भूमिका {organization} के ओनर के रूप में बदल दी" - ], - "{user} changed their role from owner to admin of {organization}": [ - "{user} ने अपनी भूमिका {organization} के एडमिन के रूप में बदल दी" - ], - "{user} was made an owner of {organization} by {actor}": [ - "{user} को {actor} द्वारा {organization} का ओनर बना दिया गया था" - ], - "{user} was made an admin of {organization} by {actor}": [ - "{user} को {actor} द्वारा {organization} का एडमिन बना दिया गया था" - ], - "(unknown)": ["(अज्ञात)"], + "{user} was invited to be owner of {organization} by {actor}": [""], + "{user} was invited to be admin of {organization} by {actor}": [""], + "{actor} invited you to be owner of {organization}": [""], + "{actor} invited you to be admin of {organization}": [""], + "You invited {user} to be owner of {organization}": [""], + "You invited {user} to be admin of {organization}": [""], + "{user} was made owner of {organization} by {actor}": [""], + "{user} was made admin of {organization} by {actor}": [""], + "{actor} made you owner of {organization}": [""], + "{actor} made you admin of {organization}": [""], + "You made {user} owner of {organization}": [""], + "You made {user} admin of {organization}": [""], + "{user} accepted an invite to be owner of {organization}": [""], + "{user} accepted an invite to be admin of {organization}": [""], + "You accepted an invite to be owner of {organization}": [""], + "You accepted an invite to be admin of {organization}": [""], + "{user}’s role was changed to owner of {organization} by {actor}": [""], + "{user}’s role was changed to admin of {organization} by {actor}": [""], + "{actor} changed your role to owner of {organization}": [""], + "{actor} changed your role to admin of {organization}": [""], + "You changed {user}’s role to owner of {organization}": [""], + "You changed {user}’s role to admin of {organization}": [""], + "{user} was removed as owner of {organization} by {actor}": [""], + "{user} was removed as admin of {organization} by {actor}": [""], + "{actor} removed you from owner of {organization}": [""], + "{actor} removed you from admin of {organization}": [""], + "You removed {user} from owner of {organization}": [""], + "You removed {user} from admin of {organization}": [""], "You are receiving this because you are an admin of this organization": [ "आप इसे प्राप्त कर रहे हैं क्योंकि आप इस संगठन के एडमिन हैं" ], + "(unknown)": ["(अज्ञात)"], "You are receiving this because you were an admin of this organization": [ "आप इसे प्राप्त कर रहे हैं क्योंकि आप इस संगठन के एडमिन थे" ], - "You removed yourself as an admin of {organization}": [ - "आपने खुद को {organization} के एडमिन के रूप में हटा दिया" - ], - "You were removed as an admin of {organization} by {actor}": [ - "आपको {actor} द्वारा {organization} के एडमिन के रूप में हटा दिया गया था" - ], - "{user} was removed as an admin of {organization} by {actor}": [ - "{user} को {actor} द्वारा {organization} के एडमिन के रूप में हटा दिया गया था" - ], + "{user} was invited to be editor and promoter of {project} by {actor}": [""], + "{user} was invited to be editor of {project} by {actor}": [""], + "{user} was invited to be promoter of {project} by {actor}": [""], + "{user} was invited to join the crew of {project} by {actor}": [""], + "{actor} invited you to be editor and promoter of {project}": [""], + "{actor} invited you to be editor of {project}": [""], + "{actor} invited you to be promoter of {project}": [""], + "{actor} invited you to join the crew of {project}": [""], + "You invited {user} to be editor and promoter of {project}": [""], + "You invited {user} to be editor of {project}": [""], + "You invited {user} to be promoter of {project}": [""], + "You invited {user} to join the crew of {project}": [""], + "{user} accepted an invite to be editor and promoter of {project}": [""], + "{user} accepted an invite to be editor of {project}": [""], + "{user} accepted an invite to be promoter of {project}": [""], + "{user} accepted an invite to join the crew of {project}": [""], + "You accepted an invite to be editor and promoter of {project}": [""], + "You accepted an invite to be promoter of {project}": [""], + "You accepted an invite to be editor of {project}": [""], + "You accepted an invite to join the crew of {project}": [""], + "{actor} joined {project} as editor and promoter": [""], + "{actor} joined {project} as editor": [""], + "{actor} joined {project} as promoter": [""], + "{actor} joined the crew of {project}": [""], + "{user} was made editor and promoter of {project} by {actor}": [""], + "{user} was made editor of {project} by {actor}": [""], + "{user} was made promoter of {project} by {actor}": [""], + "{actor} added {user} to the crew of {project}": [""], + "{actor} made you editor and promoter of {project}": [""], + "{actor} made you editor of {project}": [""], + "{actor} made you promoter of {project}": [""], + "{actor} added you to the crew of {project}": [""], + "You made {user} editor and promoter of {project}": [""], + "You made {user} editor of {project}": [""], + "You made {user} promoter of {project}": [""], + "You added {user} to the crew of {project}": [""], + "You joined {project} as editor and promoter": [""], + "You joined {project} as editor": [""], + "You joined {project} as promoter": [""], + "You joined the crew of {project}": [""], + "{user} changed their role to editor and promoter of {project}": [""], + "{user} changed their role to editor of {project}": [""], + "{user} changed their role to promoter of {project}": [""], + "{user} changed their role to crew member of {project}": [""], + "{user}’s role was changed to editor and promoter of {project} by {actor}": [""], + "{user}’s role was changed to editor of {project} by {actor}": [""], + "{user}’s role was changed to promoter of {project} by {actor}": [""], + "{user}’s role was changed to crew member of {project} by {actor}": [""], + "{actor} changed your role to editor and promoter of {project}": [""], + "{actor} changed your role to editor of {project}": [""], + "{actor} changed your role to promoter of {project}": [""], + "{actor} changed your role to crew member of {project}": [""], + "You changed {user}’s role to editor and promoter of {project}": [""], + "You changed {user}’s role to editor of {project}": [""], + "You changed {user}’s role to promoter of {project}": [""], + "You changed {user}’s role to crew member of {project}": [""], + "You are now editor and promoter of {project}": [""], + "You changed your role to editor of {project}": [""], + "You changed your role to promoter of {project}": [""], + "You changed your role to crew member of {project}": [""], + "{user} resigned as editor and promoter of {project}": [""], + "{user} resigned as editor of {project}": [""], + "{user} resigned as promoter of {project}": [""], + "{user} resigned from the crew of {project}": [""], + "{user} was removed as editor and promoter of {project} by {actor}": [""], + "{user} was removed as editor of {project} by {actor}": [""], + "{user} was removed as promoter of {project} by {actor}": [""], + "{user} was removed as crew of {project} by {actor}": [""], + "{actor} removed you as editor and promoter of {project}": [""], + "{actor} removed you as editor of {project}": [""], + "{actor} removed you as promoter of {project}": [""], + "{actor} removed you from the crew of {project}": [""], + "You resigned as editor and promoter of {project}": [""], + "You resigned as editor of {project}": [""], + "You resigned as promoter of {project}": [""], + "You resigned from the crew of {project}": [""], + "You removed {user} as editor and promoter of {project}": [""], + "You removed {user} as editor of {project}": [""], + "You removed {user} as promoter of {project}": [""], + "You removed {user} from the crew of {project}": [""], + "You are receiving this because you are a crew member of this project": [""], "You are receiving this because you have registered for this project": [ "आप इसे प्राप्त कर रहे हैं क्योंकि आपने इस प्रोजेक्ट के लिए रजिस्टर किया है" ], diff --git a/funnel/templates/about.html.jinja2 b/funnel/templates/about.html.jinja2 index 992379e58..c192d98a0 100644 --- a/funnel/templates/about.html.jinja2 +++ b/funnel/templates/about.html.jinja2 @@ -3,7 +3,7 @@ {%- from "macros.html.jinja2" import page_footer %} {% block pageheaders %} - + {% endblock pageheaders %} {% block contenthead %} diff --git a/funnel/templates/account.html.jinja2 b/funnel/templates/account.html.jinja2 index 5ca5c7323..0d8581457 100644 --- a/funnel/templates/account.html.jinja2 +++ b/funnel/templates/account.html.jinja2 @@ -7,10 +7,10 @@ {%- block pageheaders %} + href="{{ manifest('css/login_form.css') }}"/> + href="{{ manifest('css/account.css') }}"/> {%- endblock pageheaders %} {% block bodyattrs -%} class="bg-primary tabs-navbar" @@ -33,14 +33,15 @@

@{{ current_auth.user.username }}

{% else %}

- {{ faicon(icon='info-circle', icon_size='body2', baseline=true) }} Add username {{ faicon(icon='plus', icon_size='caption', baseline=false) }} + {{ faicon(icon='info-circle', icon_size='body2', baseline=true) }} {% trans %}Add username{% endtrans %} {{ faicon(icon='plus', icon_size='caption', baseline=false) }}

{%- endif %} - {% if current_auth.user.profile %} - {% trans %}Go to account{% endtrans %} {{ faicon(icon='arrow-right', icon_size='caption', baseline=false) }} - {%- endif %} + + {%- trans %}Go to account{% endtrans %} + {{ faicon(icon='arrow-right', icon_size='caption', baseline=false) }} +
@@ -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 c6ba30c90..be97f2057 100644 --- a/funnel/templates/opensearch.xml.jinja2 +++ b/funnel/templates/opensearch.xml.jinja2 @@ -4,7 +4,7 @@ {% trans %}Search Hasgeek for projects, discussions and more{% endtrans %} - Hasgeek + {% 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 935991fa9..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: @@ -98,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: @@ -121,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', @@ -156,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: @@ -227,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 @@ -247,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: @@ -260,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 7d16b86a0..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) @@ -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. @@ -237,16 +255,22 @@ 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. - - def vars(self) -> Dict[str, Any]: # noqa: A003 + 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) @@ -270,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 @@ -321,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: @@ -335,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( @@ -372,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) @@ -412,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) @@ -439,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 cec057efb..65302c043 100644 --- a/funnel/typing.py +++ b/funnel/typing.py @@ -2,98 +2,41 @@ from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TypeVar, Union -from uuid import UUID - -from sqlalchemy.orm import Mapped +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') -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 e75414f16..462fff14c 100644 --- a/funnel/utils/markdown/base.py +++ b/funnel/utils/markdown/base.py @@ -1,21 +1,11 @@ """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 @@ -27,16 +17,17 @@ front_matter, tasklists, ) -from typing_extensions import Literal 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, sub_plugin, @@ -44,131 +35,73 @@ ) 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 @@ -177,11 +110,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: @@ -191,13 +127,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 {}) @@ -224,10 +163,11 @@ def render(self, text: Optional[str]) -> Optional[Markup]: # --- Markdown plugins ----------------------------------------------------------------- -MarkdownPlugin('frontmatter', front_matter.front_matter_plugin) -MarkdownPlugin('deflists', deflist.deflist_plugin) -MarkdownPlugin('footnote', footnote.footnote_plugin) -MarkdownPlugin( +MarkdownPlugin.register('abbr', abbr_plugin) +MarkdownPlugin.register('frontmatter', front_matter.front_matter_plugin) +MarkdownPlugin.register('deflists', deflist.deflist_plugin) +MarkdownPlugin.register('footnote', footnote.footnote_plugin) +MarkdownPlugin.register( 'heading_anchors', anchors.anchors_plugin, { @@ -236,40 +176,48 @@ 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.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( +MarkdownPlugin.register( 'tab_container', container.container_plugin, {'name': 'tab', 'marker': ':', 'render': render_tab}, ) -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('toc', toc_plugin) +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) +# 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=['frontmatter', 'block_code_ext'], ) -MarkdownConfig( +markdown_document = MarkdownConfig.register( name='document', preset='gfm-like', options_update={ @@ -281,11 +229,13 @@ def render(self, text: Optional[str]) -> Optional[Markup]: plugins=[ 'frontmatter', 'tab_container', + 'abbr', 'block_code_ext', 'deflists', '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', @@ -293,13 +243,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. @@ -308,7 +272,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 d10793bea..e88dac2c3 100644 --- a/funnel/utils/markdown/mdit_plugins/__init__.py +++ b/funnel/utils/markdown/mdit_plugins/__init__.py @@ -1,10 +1,12 @@ """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 .sub_tag import * 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 e84df5572..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 @@ -67,7 +67,7 @@ def embeds_func( # 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 @@ -115,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 index 74314f432..ab77c0a23 100644 --- a/funnel/utils/markdown/tabs.py +++ b/funnel/utils/markdown/tabs.py @@ -3,14 +3,14 @@ from dataclasses import dataclass, field from functools import reduce -from typing import Any, ClassVar, Dict, List, Optional, Tuple +from typing import Any, ClassVar from markdown_it.token import Token __all__ = ['render_tab'] -def render_tab(self, tokens: List[Token], idx, _options, env): +def render_tab(self, tokens: list[Token], idx, _options, env): if 'manager' not in env: env['manager'] = TabsManager(tokens) @@ -25,13 +25,13 @@ def render_tab(self, tokens: List[Token], idx, _options, env): @dataclass class TabsetNode: start: int - parent: Optional[TabNode] = None - children: List[TabNode] = field(default_factory=list) + 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]: + def flatten(self) -> list[TabNode]: tabs = self.children for tab in self.children: for tabset in tab.children: @@ -63,7 +63,7 @@ class TabNode: key: str parent: TabsetNode _tab_id: str = '' - children: List[TabsetNode] = field(default_factory=list) + children: list[TabsetNode] = field(default_factory=list) _opening: ClassVar[str] = ( '
      ' @@ -89,7 +89,7 @@ def _item_aria(self): return ' tabindex="0" aria-selected="true"' return ' tabindex="-1" aria-selected="false"' - def flatten(self) -> List[TabsetNode]: + def flatten(self) -> list[TabsetNode]: tabsets = self.children for tabset in self.children: for tab in tabset.children: @@ -147,22 +147,22 @@ def html_tab_item(self): class TabsManager: - tabsets: List[TabsetNode] - _index: Dict[int, TabNode] + tabsets: list[TabsetNode] + _index: dict[int, TabNode] - def __init__(self, tokens: List[Token]) -> None: + def __init__(self, tokens: list[Token]) -> None: tab_tokens = self._get_tab_tokens(tokens) - self.tabsets: List[TabsetNode] = self.make(tab_tokens) + self.tabsets: list[TabsetNode] = self.make(tab_tokens) self._index = {} self.index() def make( - self, tab_tokens: List[Dict[str, Any]], parent: Optional[TabNode] = None - ) -> List[TabsetNode]: + 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: Optional[TabNode] = None + 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 @@ -197,7 +197,7 @@ def make( return tabsets - def _get_tab_tokens(self, tokens: List[Token]) -> List[Dict[str, Any]]: + def _get_tab_tokens(self, tokens: list[Token]) -> list[dict[str, Any]]: return [ { 'index': i, @@ -210,8 +210,8 @@ def _get_tab_tokens(self, tokens: List[Token]) -> List[Dict[str, Any]]: ] def _tab_token_pair( - self, tab_tokens: List[Dict[str, Any]], start=0 - ) -> Optional[Tuple[int, int]]: + self, tab_tokens: list[dict[str, Any]], start=0 + ) -> tuple[int, int] | None: i = 1 while i < len(tab_tokens): if ( @@ -224,21 +224,21 @@ def _tab_token_pair( return None return (start, start + i) - def index(self, start: Optional[int] = None) -> Optional[TabNode]: + 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] = [] + 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( + tabs: list[TabNode] = reduce( lambda tablist, tabset: tablist + tabset.flatten(), self.tabsets, [] ) for i, tab in enumerate(tabs): 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 170d71a00..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,12 +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, + 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']) @@ -420,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), ) ), ) @@ -458,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() @@ -484,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 + 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', ) @@ -514,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'] @@ -542,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'] @@ -572,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( @@ -596,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"), @@ -615,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. @@ -625,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 @@ -638,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) @@ -657,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', ) @@ -700,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(sms=True) + 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"), @@ -734,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"), @@ -765,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"), @@ -806,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, 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 f85f602d6..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 @@ -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 @@ -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 8eac9f78f..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,7 +460,9 @@ 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( @@ -484,27 +481,31 @@ def resource_id(authtoken: AuthToken, args: dict, files=None) -> ReturnResource: @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.effective_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 { @@ -520,7 +521,9 @@ def resource_email(authtoken: AuthToken, args: dict, files=None) -> ReturnResour @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 { @@ -537,10 +540,10 @@ 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.effective_user.externalids: if service is None or extid.service == service: @@ -559,7 +562,7 @@ 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( @@ -572,7 +575,9 @@ 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.effective_user, 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 7caf880a3..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']) @@ -117,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 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 b0299ab9a..d71ce9b87 100644 --- a/funnel/views/auth_client.py +++ b/funnel/views/auth_client.py @@ -2,10 +2,7 @@ from __future__ import annotations -from typing import List, Tuple -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - -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 @@ -24,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 @@ -59,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)) @@ -82,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() @@ -108,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']) @@ -129,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( @@ -151,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()) @@ -236,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"), ) @@ -316,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) @@ -345,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() ) @@ -379,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', ) @@ -409,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 887374a7d..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() @@ -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 d6e1b5b3e..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 ----------------------------------------------------------------- @@ -518,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: @@ -566,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. @@ -580,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 9ef3aabbc..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,7 +189,7 @@ 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: 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 index dbe14a8ac..540ec8d7f 100644 --- a/tests/unit/utils/markdown/data/tabs.toml +++ b/tests/unit/utils/markdown/data/tabs.toml @@ -480,7 +480,7 @@ This tab is going to have a table!
      :::: """ -document = """

      Tabs using containers plugin #

      +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>”.

      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 90%
      rename from tests/unit/utils/markdown/test_markdown.py
      rename to tests/unit/utils/markdown/markdown_test.py
      index 92253f087..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,9 +23,6 @@ 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:
      @@ -42,7 +38,7 @@ def test_markdown_cases(
       
       
       @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 91% rename from tests/unit/views/test_search.py rename to tests/unit/views/search_test.py index 66b2df2cb..3e012acce 100644 --- a/tests/unit/views/test_search.py +++ b/tests/unit/views/search_test.py @@ -5,16 +5,16 @@ 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, @@ -23,7 +23,7 @@ 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) @@ -54,8 +54,8 @@ def test_search_all_count_returns_int(stype, all_fixtures) -> None: 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( - get_tsquery("test"), org_ankhmorpork.profile + cast(SearchInAccountProvider, search_providers[stype]).account_count( + get_tsquery("test"), org_ankhmorpork ), int, ) @@ -84,8 +84,8 @@ def test_search_all_returns_query(stype, all_fixtures) -> None: 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( - get_tsquery("test"), org_ankhmorpork.profile + cast(SearchInAccountProvider, search_providers[stype]).account_query( + get_tsquery("test"), org_ankhmorpork ), Query, ) @@ -109,7 +109,7 @@ def test_search_project_returns_query(stype, project_expo2010, all_fixtures) -> def test_search_counts(org_ankhmorpork, project_expo2010) -> None: """Test that search_counts returns a list of dicts.""" r1 = search_counts(get_tsquery("test")) - r2 = search_counts(get_tsquery("test"), profile=org_ankhmorpork.profile) + r2 = search_counts(get_tsquery("test"), account=org_ankhmorpork) r3 = search_counts(get_tsquery("test"), project=project_expo2010) for resultset in (r1, r2, r3): @@ -126,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('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() @@ -174,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 100% rename from tests/unit/views/test_sitemap.py rename to tests/unit/views/sitemap_test.py index 4eb3107da..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 diff --git a/tests/unit/views/test_notification.py b/tests/unit/views/test_notification.py deleted file mode 100644 index 66a8f8439..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( - notification_type='', - user=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, - 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 4dc9632be..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: [ @@ -134,9 +142,6 @@ module.exports = { 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 +)