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 ef4d50569..dc177a25e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,42 @@ * 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 36abebcb6..225d8256e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -6,13 +6,14 @@ 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' - '.github/workflows/pytest.yml' @@ -20,15 +21,20 @@ on: 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: @@ -50,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) @@ -62,15 +71,35 @@ 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-ci + run: make install-npm - name: Build Webpack assets run: make assets - name: Annotate Pytest failures in PR @@ -85,6 +114,7 @@ jobs: 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);" @@ -93,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 --gherkin-terminal-reporter -vv --showlocals --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 c46b48d1f..feb7b1e99 100644 --- a/.github/workflows/telegram.yml +++ b/.github/workflows/telegram.yml @@ -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 7c2f0dd03..1221e3fd3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,13 +2,19 @@ # 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 @@ -17,10 +23,11 @@ repos: - id: pip-compile-multi-verify files: ^requirements/.*\.(in|txt)$ - repo: https://github.com/pypa/pip-audit - rev: v2.5.5 + rev: v2.6.1 hooks: - id: pip-audit args: [ + '--disable-pip', '--no-deps', '--skip-editable', '-r', @@ -33,10 +40,21 @@ 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 + '--ignore-vuln', + 'PYSEC-2023-206', # pytest-selenium again ] files: ^requirements/.*\.txt$ - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.265 + - 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.1 hooks: - id: ruff args: ['--fix', '--exit-non-zero-on-fix'] @@ -53,18 +71,13 @@ repos: '--remove-unused-variables', '--remove-duplicate-keys', ] - - repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 - 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 @@ -87,38 +100,43 @@ repos: additional_dependencies: - tomli - repo: https://github.com/psf/black - rev: 23.3.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: v3.0.0a6 + 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: - tomli @@ -131,7 +149,7 @@ repos: additional_dependencies: - '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 @@ -164,8 +182,15 @@ repos: 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.9-for-vscode + rev: v3.0.3 hooks: - id: prettier args: @@ -176,3 +201,16 @@ repos: 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/.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 e6a606df9..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" @@ -40,6 +40,22 @@ babeljs: 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; @@ -56,13 +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 @@ -103,13 +120,16 @@ install-python: install-python-pip deps-editable 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 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/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 5ba4e15a7..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 = 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, mock_transports=True) + 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 39312e872..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,67 +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', - 'select2-material', - 'getdevicepixelratio', - 'funnel-mui', - ], - theme='funnel', - 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( @@ -182,10 +173,10 @@ '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'), @@ -193,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] @@ -215,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/app.js b/funnel/assets/js/app.js index 0b999111f..92666baeb 100644 --- a/funnel/assets/js/app.js +++ b/funnel/assets/js/app.js @@ -1,7 +1,5 @@ import 'jquery-modal'; import 'trunk8'; -import jstz from 'jstz'; -import 'jquery.cookie'; import Utils from './utils/helper'; import WebShare from './utils/webshare'; import ScrollHelper from './utils/scrollhelper'; @@ -13,6 +11,9 @@ 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'); @@ -61,11 +62,7 @@ $(() => { document.head.appendChild(polyfill); } - // Detect timezone for login - if ($.cookie('timezone') === null) { - $.cookie('timezone', jstz.determine().name(), { path: '/' }); - } - + setTimezoneCookie(); updateParsleyConfig(); }); diff --git a/funnel/assets/js/form.js b/funnel/assets/js/form.js index 963a834d6..b4d896ccd 100644 --- a/funnel/assets/js/form.js +++ b/funnel/assets/js/form.js @@ -1,18 +1,15 @@ /* global grecaptcha */ - -import { - activateFormWidgets, - EnableAutocompleteWidgets, - MapMarker, -} from './utils/form_widgets'; +import { activateFormWidgets, MapMarker } from './utils/form_widgets'; import Form from './utils/formhelper'; import 'htmx.org'; window.Hasgeek.initWidgets = async function init(fieldName, config) { switch (fieldName) { - case 'AutocompleteField': - EnableAutocompleteWidgets.textAutocomplete(config); + case 'AutocompleteField': { + const { default: widget } = await import('./utils/autocomplete_widget'); + widget.textAutocomplete(config); break; + } case 'ImgeeField': window.addEventListener('message', (event) => { if (event.origin === config.host) { @@ -29,12 +26,16 @@ window.Hasgeek.initWidgets = async function init(fieldName, config) { $(`#imgee-loader-${config.fieldId}`).addClass('mui--hide'); }); break; - case 'UserSelectField': - EnableAutocompleteWidgets.lastuserAutocomplete(config); + case 'UserSelectField': { + const { default: lastUserWidget } = await import('./utils/autocomplete_widget'); + lastUserWidget.lastuserAutocomplete(config); break; - case 'GeonameSelectField': - EnableAutocompleteWidgets.geonameAutocomplete(config); + } + case 'GeonameSelectField': { + const { default: geonameWidget } = await import('./utils/autocomplete_widget'); + geonameWidget.geonameAutocomplete(config); break; + } case 'CoordinatesField': /* eslint-disable no-new */ await import('jquery-locationpicker'); 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/membership.js b/funnel/assets/js/membership.js index 4b74105a9..2f1ca38f5 100644 --- a/funnel/assets/js/membership.js +++ b/funnel/assets/js/membership.js @@ -17,15 +17,15 @@ 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: Utils.getInitials, @@ -114,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; diff --git a/funnel/assets/js/notification_settings.js b/funnel/assets/js/notification_settings.js index 063ba6ac1..5f6ac0347 100644 --- a/funnel/assets/js/notification_settings.js +++ b/funnel/assets/js/notification_settings.js @@ -1,11 +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]; diff --git a/funnel/assets/js/project_header.js b/funnel/assets/js/project_header.js index a32483286..e352f3470 100644 --- a/funnel/assets/js/project_header.js +++ b/funnel/assets/js/project_header.js @@ -1,165 +1,10 @@ -import { AJAX_TIMEOUT, RETRY_INTERVAL } from './constants'; import SaveProject from './utils/bookmark'; import Video from './utils/embedvideo'; -import Analytics from './utils/analytics'; import Spa from './utils/spahelper'; import { Widgets } from './utils/form_widgets'; import initEmbed from './utils/initembed'; import SortItem from './utils/sort'; - -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(); - } - } - }, -}; +import Ticketing from './utils/ticket_widget'; $(() => { window.Hasgeek.projectHeaderInit = ( @@ -167,7 +12,8 @@ $(() => { saveProjectConfig = '', tickets = '', toggleId = '', - sort = '' + sort = '', + rsvpModalHash = 'register-modal' ) => { if (saveProjectConfig) { SaveProject(saveProjectConfig); @@ -189,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'); } diff --git a/funnel/assets/js/rsvp_form_modal.js b/funnel/assets/js/rsvp_form_modal.js new file mode 100644 index 000000000..52bd43be7 --- /dev/null +++ b/funnel/assets/js/rsvp_form_modal.js @@ -0,0 +1,34 @@ +import Vue from 'vue/dist/vue.esm'; +import jsonForm from './utils/jsonform'; + +Vue.config.devtools = true; + +const FormUI = { + init(jsonSchema, useremail) { + /* eslint-disable no-new */ + new Vue({ + el: '#register-form', + data() { + return { + jsonSchema, + useremail, + }; + }, + components: { + jsonForm, + }, + methods: { + handleAjaxPost() { + window.location.hash = ''; + window.location.reload(); + }, + }, + }); + }, +}; + +$(() => { + window.Hasgeek.addRsvpForm = (jsonSchema, useremail) => { + FormUI.init(jsonSchema, useremail); + }; +}); diff --git a/funnel/assets/js/schedule_view.js b/funnel/assets/js/schedule_view.js index f8fbacbf8..63875eecd 100644 --- a/funnel/assets/js/schedule_view.js +++ b/funnel/assets/js/schedule_view.js @@ -84,29 +84,35 @@ 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); @@ -237,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_form.js b/funnel/assets/js/submission_form.js index ca6fbb43d..c24996a41 100644 --- a/funnel/assets/js/submission_form.js +++ b/funnel/assets/js/submission_form.js @@ -79,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) => { @@ -128,6 +127,7 @@ $(() => { const markdownId = $(`#${formId}`).find('textarea.markdown').attr('id'); codemirrorHelper(markdownId, updatePreview); + initEmbed(markdownPreviewElem); $('#title') .keypress((event) => { diff --git a/funnel/assets/js/utils/analytics.js b/funnel/assets/js/utils/analytics.js index dd6889a33..d7e751064 100644 --- a/funnel/assets/js/utils/analytics.js +++ b/funnel/assets/js/utils/analytics.js @@ -1,13 +1,11 @@ -/* 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, }); } }, @@ -18,6 +16,10 @@ const Analytics = { 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); 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/codemirror.js b/funnel/assets/js/utils/codemirror.js index 90fbade6e..48f3278d7 100644 --- a/funnel/assets/js/utils/codemirror.js +++ b/funnel/assets/js/utils/codemirror.js @@ -55,9 +55,7 @@ function codemirrorHelper(markdownId, updateFnCallback = '', callbackInterval = } }, }); - if ($(`#${markdownId}`).hasClass('activated')) { - $(`#${markdownId}`).next().remove(); - } + $(`#${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 index 7efb8a33f..cf2a54466 100644 --- a/funnel/assets/js/utils/form_widgets.js +++ b/funnel/assets/js/utils/form_widgets.js @@ -60,13 +60,6 @@ export const Widgets = { }; this.activateToggleSwitch(checkboxId, onSuccess); }, - activate_select2() { - /* Upgrade to jquery 3.6 select2 autofocus isn't working. This is to fix that problem. - select2/select2#5993 */ - $(document).on('select2:open', () => { - document.querySelector('.select2-search__field').focus(); - }); - }, handleDelete(elementClass, onSucessFn) { $('body').on('click', elementClass, async function remove(event) { event.preventDefault(); @@ -128,8 +121,8 @@ export async function activateFormWidgets() { } }); - // Change username field input mode to tel - if ($('#username').length > 0) { + // 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'); @@ -151,138 +144,37 @@ export async function activateFormWidgets() { ); if ( - $('textarea.markdown:not([style*="display: none"]:not(.activating):not(.activated)') - .length + $( + 'textarea.markdown:not([style*="display: none"], .activating, .activated, .no-codemirror)' + ).length ) { const { default: codemirrorHelper } = await import('./codemirror'); - $('textarea.markdown:not([style*="display: none"]').each( - function enableCodemirror() { - const markdownId = $(this).attr('id'); - $(`#${markdownId}`).addClass('activating'); - codemirrorHelper(markdownId); - } - ); + $( + 'textarea.markdown:not([style*="display: none"]:not(.activating):not(.activated)' + ).each(function enableCodemirror() { + const markdownId = $(this).attr('id'); + $(`#${markdownId}`).addClass('activating'); + codemirrorHelper(markdownId); + }); } - Widgets.activate_select2(); -} - -export const EnableAutocompleteWidgets = { - lastuserAutocomplete(options) { - const assembleUsers = function getUsers(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 }; - }, - }, + 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); }); - - // 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 class MapMarker { constructor(field) { diff --git a/funnel/assets/js/utils/formhelper.js b/funnel/assets/js/utils/formhelper.js index d695db6e3..9c957785d 100644 --- a/funnel/assets/js/utils/formhelper.js +++ b/funnel/assets/js/utils/formhelper.js @@ -158,17 +158,21 @@ const Form = { } }, ajaxFormSubmit(formId, url, onSuccess, onError, config) { + const formData = $(`#${formId}`).serialize(); $.ajax({ url, type: 'POST', - data: $(`#${formId}`).serialize(), + 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) { - onSuccess(responseData); + if (onSuccess) onSuccess(responseData); }, error(xhr) { onError(xhr); 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/helper.js b/funnel/assets/js/utils/helper.js index b0bfbb9b1..ecd116762 100644 --- a/funnel/assets/js/utils/helper.js +++ b/funnel/assets/js/utils/helper.js @@ -144,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); diff --git a/funnel/assets/js/utils/jsonform.js b/funnel/assets/js/utils/jsonform.js new file mode 100644 index 000000000..c80805d83 --- /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', 'useremail'], + 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/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/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 d34cae967..960da4388 100644 --- a/funnel/assets/js/utils/ractive_util.js +++ b/funnel/assets/js/utils/ractive_util.js @@ -5,13 +5,13 @@ 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 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: Utils.getInitials, imgurl() { - return `${this.get('user').avatar}?size=${encodeURIComponent( + return `${this.get('user').logo_url}?size=${encodeURIComponent( USER_AVATAR_IMG_SIZE[this.get('size')] )}`; }, 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/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 21ba426c3..aa4eb056d 100644 --- a/funnel/assets/js/utils/vue_util.js +++ b/funnel/assets/js/utils/vue_util.js @@ -5,7 +5,7 @@ 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: { @@ -26,7 +26,7 @@ export const userAvatarUI = Vue.component('useravatar', { 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)}`; }, }, }); diff --git a/funnel/assets/js/utils/webshare.js b/funnel/assets/js/utils/webshare.js index b50c8b43d..c989b2f95 100644 --- a/funnel/assets/js/utils/webshare.js +++ b/funnel/assets/js/utils/webshare.js @@ -59,7 +59,7 @@ const WebShare = { if (document.execCommand('copy')) { toastr.success(gettext('Link copied')); } else { - toastr.success(gettext('Could not copy link')); + toastr.error(gettext('Could not copy link')); } selection.removeAllRanges(); } @@ -71,9 +71,10 @@ const WebShare = { .then((shortlink) => { $(linkElem).find('.js-copy-url').text(shortlink); $(linkElem).attr('data-shortlink', true); - }) - .finally(() => { copyLink(); + }) + .catch((errMsg) => { + toastr.error(errMsg); }); } }); diff --git a/funnel/assets/sass/base/_utils.scss b/funnel/assets/sass/base/_utils.scss index 514b9a5f3..ad53ee2f1 100644 --- a/funnel/assets/sass/base/_utils.scss +++ b/funnel/assets/sass/base/_utils.scss @@ -98,6 +98,10 @@ align-items: flex-start; } +.flex-wrapper--end { + align-items: flex-end; +} + .flex-wrapper--space-between { justify-content: space-between; } @@ -239,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 6672842de..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; } diff --git a/funnel/assets/sass/components/_card.scss b/funnel/assets/sass/components/_card.scss index 711e4517b..879bf8f80 100644 --- a/funnel/assets/sass/components/_card.scss +++ b/funnel/assets/sass/components/_card.scss @@ -23,8 +23,14 @@ .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 * 0.5 $mui-grid-padding; word-break: break-word; @@ -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, @@ -364,11 +374,6 @@ 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 * 0.25 0 0; @@ -387,10 +392,6 @@ max-width: calc(100% - 20px); } - .card__body__location { - margin: 0 0 $mui-grid-padding * 0.5; - float: left; - } .card__body__divider { height: 4px; background-color: $mui-bg-color-primary-dark; @@ -425,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; 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/_markdown.scss b/funnel/assets/sass/components/_markdown.scss index 67835270c..4184faf36 100644 --- a/funnel/assets/sass/components/_markdown.scss +++ b/funnel/assets/sass/components/_markdown.scss @@ -17,19 +17,6 @@ color: $mui-text-dark; text-decoration: none; } - a.header-anchor { - display: none; - } - &:hover a.header-anchor { - display: inline; - color: $mui-text-accent; - } - @media (any-pointer: coarse) { - a.header-anchor { - display: inline; - color: $mui-text-accent; - } - } } img { diff --git a/funnel/assets/sass/components/_ticket-modal.scss b/funnel/assets/sass/components/_ticket-modal.scss new file mode 100644 index 000000000..610772eb1 --- /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: 150px; + 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 11fb2356c..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 @@ -87,7 +88,8 @@ } // Codemirror editor will be initialized - textarea.markdown { + textarea.markdown, + textarea.stylesheet { display: none; } @@ -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/mixins/_util.scss b/funnel/assets/sass/mui/mixins/_util.scss index eff085687..27d70093d 100644 --- a/funnel/assets/sass/mui/mixins/_util.scss +++ b/funnel/assets/sass/mui/mixins/_util.scss @@ -187,7 +187,7 @@ b, strong { - font-weight: 700er; + font-weight: 700; } /** diff --git a/funnel/assets/sass/pages/index.scss b/funnel/assets/sass/pages/index.scss index 19451055c..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; + } } } diff --git a/funnel/assets/sass/pages/project.scss b/funnel/assets/sass/pages/project.scss index 0d8b0ed1d..9bb8650d9 100644 --- a/funnel/assets/sass/pages/project.scss +++ b/funnel/assets/sass/pages/project.scss @@ -4,6 +4,7 @@ @import '../components/switch'; @import '../components/footable'; @import '../components/draggablebox'; +@import '../components/ticket-modal'; @import 'leaflet'; @media (min-width: 768px) { @@ -70,35 +71,16 @@ margin-bottom: $mui-grid-padding * 0.25; } .register-block__btn { + @extend .price-btn; width: 100%; - font-size: 10px; - padding: 0; - display: flex; - flex-direction: column; - align-items: center; - margin: 0 0 2px; - justify-content: center; - height: 42px; .register-block__btn__txt { - display: block; - width: 100%; - font-size: 12px; - line-height: 16px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; + @extend .price-btn__txt; } .register-block__btn__txt--hover--show { display: none; } .register-block__btn__txt--smaller { - font-size: 10px; - line-height: 16px; - text-transform: initial; - font-weight: 400; - text-overflow: ellipsis; - white-space: nowrap; - overflow: auto; + @extend .price-btn__txt--smaller; } &:hover .register-block__btn__txt--hover { display: none; @@ -111,6 +93,9 @@ border-color: $mui-text-danger; } } + .register-block__btn.mui--is-disabled:hover { + border-color: inherit; + } } .register-block__content--half { width: calc(50% - 8px); @@ -152,6 +137,9 @@ display: none; } } + .register-block__btn.mui--is-disabled { + border-color: inherit; + } } .register-block__content--half { .register-block__content__rsvp-txt { @@ -275,6 +263,7 @@ display: flex; align-items: center; margin-bottom: $mui-grid-padding * 0.5; + flex-wrap: wrap; .project-banner__profile-details__logo-wrapper { display: inline-block; @@ -290,6 +279,9 @@ object-fit: cover; } } + .project-banner__profile-details__badge { + margin-left: auto; + } } .project-banner__profile-details--center { @@ -536,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 * 0.5; - margin-top: $mui-grid-padding * 0.5; - } - } - } -} - .about .rsvp-wrapper { padding-top: 50px; } @@ -655,22 +589,6 @@ background: $mui-bg-color-primary; } -.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; - } -} - .label { padding: 4px 8px; font-size: 12px; diff --git a/funnel/assets/sass/pages/schedule.scss b/funnel/assets/sass/pages/schedule.scss index d2d93bbb9..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; @@ -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) { @@ -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 * 0.5 0; + align-self: center; } } .schedule__row--calendar { @@ -407,7 +430,7 @@ } .fc-event .fc-event-custom a { - font-weight: 700er; + font-weight: 700; color: #c33; font-size: 1.2em; } diff --git a/funnel/assets/sass/pages/submission.scss b/funnel/assets/sass/pages/submission.scss index 7d4845e08..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 { 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 f2f6a246e..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: @@ -177,12 +174,11 @@ def install_mock(func: Callable, mock: Callable) -> None: def _prepare_subprocess( - engines: Iterable[Engine], 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,18 +188,20 @@ def _prepare_subprocess( 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( 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: capture = CapturedEmail( subject, @@ -228,7 +226,7 @@ def mock_sms( # 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) @@ -251,9 +249,9 @@ class BackgroundWorker: 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, @@ -266,10 +264,10 @@ def __init__( 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() @@ -279,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, @@ -328,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 @@ -377,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 c0ddc7b73..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")) @@ -392,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'}, @@ -415,7 +413,7 @@ class AccountForm(forms.Form): ), 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/", @@ -442,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( __( @@ -475,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"), @@ -487,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, @@ -529,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.""" @@ -546,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"), @@ -576,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/helpers.py b/funnel/forms/helpers.py index 8e4c8c035..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_is(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,36 +213,37 @@ 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_list_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: @@ -238,7 +254,7 @@ def video_url_list_validator(form, field): ) from None -def video_url_validator(form, field): +def video_url_validator(form: forms.Form, field: forms.Field) -> None: """Validate the video URL to be acceptable.""" try: parse_video_url(field.data) @@ -255,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 38163b99b..7acd78465 100644 --- a/funnel/forms/membership.py +++ b/funnel/forms/membership.py @@ -5,7 +5,7 @@ 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__ = [ @@ -15,7 +15,7 @@ ] -@OrganizationMembership.forms('main') +@AccountMembership.forms('main') class OrganizationMembershipForm(forms.Form): """Form to add a member to an organization (admin or owner).""" @@ -38,7 +38,7 @@ class OrganizationMembershipForm(forms.Form): ) -@ProjectCrewMembership.forms('main') +@ProjectMembership.forms('main') class ProjectCrewMembershipForm(forms.Form): """Form to add a project crew member.""" @@ -72,7 +72,7 @@ class ProjectCrewMembershipForm(forms.Form): 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]): @@ -81,7 +81,7 @@ def validate(self, *args, **kwargs): 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 9a322cd2e..bc8342fde 100644 --- a/funnel/forms/notification.py +++ b/funnel/forms/notification.py @@ -2,15 +2,15 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, List, Optional 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__ = [ @@ -26,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 @@ -116,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 @@ -181,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 @@ -207,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 da28ffe14..077b96454 100644 --- a/funnel/forms/organization.py +++ b/funnel/forms/organization.py @@ -2,25 +2,25 @@ from __future__ import annotations -from typing import Iterable, Optional +from collections.abc import Iterable 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"), @@ -29,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'}, @@ -43,16 +43,16 @@ class OrganizationForm(forms.Form): ), 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': @@ -61,12 +61,18 @@ def validate_name(self, field) -> None: ) 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 169fd51d8..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 = __( @@ -80,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()] @@ -95,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"), @@ -119,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"), @@ -146,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 b3a924dcc..546bd969a 100644 --- a/funnel/forms/project.py +++ b/funnel/forms/project.py @@ -2,18 +2,19 @@ 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 ..models import Account, Project, Rsvp, SavedProject from .helpers import ( - ProfileSelectField, + AccountSelectField, image_url_validator, + nullable_json_filters, nullable_strip_filters, + validate_and_convert_json, video_url_list_validator, ) @@ -29,6 +30,7 @@ 'ProjectSponsorForm', 'RsvpTransitionForm', 'SavedProjectForm', + 'ProjectRegisterForm', ] double_quote_re = re.compile(r'["“”]') @@ -39,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"), @@ -116,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( @@ -124,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 @@ -174,11 +176,15 @@ class ProjectLivestreamForm(forms.Form): ], ) + 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( @@ -209,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"), @@ -228,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') @@ -251,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( @@ -301,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', @@ -349,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 2232a5341..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"), @@ -208,10 +208,10 @@ class ProposalMemberForm(forms.Form): ) 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 75c825ab4..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,20 +69,37 @@ 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') @@ -121,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( @@ -131,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"), @@ -175,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 59ffc5770..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 _ 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 a5a601ed5..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)' diff --git a/funnel/loginproviders/twitter.py b/funnel/loginproviders/twitter.py index 74849ae81..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 _ diff --git a/funnel/loginproviders/zoom.py b/funnel/loginproviders/zoom.py index 3aaec6424..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 _ diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index 84f2b24e4..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.orm import DeclarativeBase, Mapped, declarative_mixin, declared_attr from sqlalchemy_utils import LocaleType, TimezoneType, TSVectorType -import sqlalchemy as sa # noqa -import sqlalchemy.orm # Required to make sa.orm work # noqa 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..bbc34da48 --- /dev/null +++ b/funnel/models/account.py @@ -0,0 +1,2186 @@ +"""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( + 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', + 'absolute_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', + 'absolute_url', + 'is_verified', + }, + 'related': { + 'urls', + 'uuid_b58', + 'name', + 'urlname', + 'title', + 'fullname', + 'username', + 'pickername', + 'timezone', + 'description', + 'logo_url', + 'joined_at', + 'absolute_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'}) + + 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 + absolute_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', + 'absolute_url', + }, + 'call': {'views', 'forms', 'features', 'url_for'}, + } + } + + __datasets__ = { + 'related': { + 'username', + 'fullname', + 'pickername', + 'absolute_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 c4f81bb07..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,83 +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) + 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.Column(sa.String(250), default='bearer', nullable=False) + 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) + algorithm = sa.orm.mapped_column(sa.String(20), nullable=True) #: Token's validity period in seconds, 0 = unlimited - validity = sa.Column(sa.Integer, nullable=False, default=0) + 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) @@ -508,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)) @@ -520,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() ) @@ -537,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 @@ -556,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. @@ -590,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 @@ -605,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(' ')) @@ -708,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 ) @@ -773,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 deeb6e3ac..c0361d9e6 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__ = ( @@ -258,16 +278,16 @@ class Comment(UuidMixin, BaseMixin, db.Model): # type: ignore[name-defined] __roles__ = { 'all': { - 'read': {'created_at', 'urls', 'uuid_b58', 'has_replies'}, + 'read': {'created_at', 'urls', 'uuid_b58', 'has_replies', 'absolute_url'}, '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__ = { - 'primary': {'created_at', 'urls', 'uuid_b58'}, - 'related': {'created_at', 'urls', 'uuid_b58'}, - 'json': {'created_at', 'urls', 'uuid_b58'}, + 'primary': {'created_at', 'urls', 'uuid_b58', 'absolute_url'}, + 'related': {'created_at', 'urls', 'uuid_b58', 'absolute_url'}, + 'json': {'created_at', 'urls', 'uuid_b58', 'absolute_url'}, 'minimal': {'created_at', 'uuid_b58'}, } @@ -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): # 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): # pylint: disable=no-self-argument + @message.inplace.expression + @classmethod + def _message_expression(cls): """Return SQL expression for comment message column.""" return cls._message @@ -336,32 +361,26 @@ def message(cls): # pylint: disable=no-self-argument message, read={'all'}, datasets={'primary', 'related', 'json', 'minimal'} ) - @property - def absolute_url(self) -> str: - return self.url_for() - - with_roles(absolute_url, read={'all'}, datasets={'primary', 'related', 'json'}) - @property 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 +397,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 +419,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 +431,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 04d91875d..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, db, json_type, postgresql, 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(postgresql.UUID, primary_key=True) - body = sa.Column(json_type, nullable=False, server_default='{}') - revision: Mapped[Optional[UUID]] = sa.Column(postgresql.UUID) + 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 f18401473..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 markupsafe import Markup -from markupsafe 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,7 +35,7 @@ 'add_search_trigger', 'visual_field_delimiter', 'valid_name', - 'valid_username', + 'valid_account_name', 'quote_autocomplete_like', 'quote_autocomplete_tsquery', 'ImgeeFurl', @@ -60,7 +46,7 @@ 'MarkdownCompositeInline', ] -RESERVED_NAMES: Set[str] = { +RESERVED_NAMES: set[str] = { '_baseframe', 'about', 'account', @@ -166,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 @@ -179,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) @@ -197,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_]*$', 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) @@ -214,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. @@ -231,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}") @@ -279,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: @@ -320,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 underscores only. """ - return _username_valid_re.search(candidate) is not None + return _account_name_valid_re.search(candidate) is not None def valid_name(candidate: str) -> bool: @@ -343,7 +328,7 @@ def pgquote(identifier: str) -> str: return f'"{identifier}"' if identifier in POSTGRESQL_RESERVED_WORDS else identifier -def quote_autocomplete_like(prefix, midway=False) -> str: +def quote_autocomplete_like(prefix: str, midway: bool = False) -> str: """ Construct a LIKE query string for prefix-based matching (autocomplete). @@ -372,7 +357,11 @@ def quote_autocomplete_like(prefix, midway=False) -> str: # Some SQL dialects respond to '[' and ']', so remove them. # Suffix a '%' to make a prefix-match query. like_query = ( - prefix.replace('%', r'\%').replace('_', r'\_').replace('[', '').replace(']', '') + prefix.replace('\\', r'\\') + .replace('%', r'\%') + .replace('_', r'\_') + .replace('[', '') + .replace(']', '') + '%' ) lstrip_like_query = like_query.lstrip() @@ -394,24 +383,23 @@ def quote_autocomplete_tsquery(prefix: str) -> 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( @@ -470,13 +458,14 @@ 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__), ) ) @@ -520,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__()} @@ -548,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. @@ -568,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', []) @@ -583,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) @@ -652,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 b7ace4cdc..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' ) @@ -183,45 +190,51 @@ def restricted(self) -> bool: # pylint: disable=protected-access return self.main_label._restricted if self.main_label else self._restricted - @restricted.setter - def restricted(self, value: 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): # 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): # 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, ) @@ -230,9 +243,11 @@ def archived(cls): # pylint: disable=no-self-argument def has_options(self) -> bool: return bool(self.options) - @has_options.expression - def has_options(cls): # 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: @@ -243,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 @@ -308,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, @@ -372,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) @@ -383,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, @@ -393,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', @@ -405,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 707958fb3..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): # 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 8d7e747a5..024d238dc 100644 --- a/funnel/models/notification.py +++ b/funnel/models/notification.py @@ -76,41 +76,36 @@ 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 typing_extensions import Protocol - from baseframe import __ from coaster.sqlalchemy import ( - Query, Registry, SqlUuidB58Comparator, auto_init_default, @@ -119,11 +114,25 @@ ) from coaster.utils import LabeledEnum, uuid_from_base58, uuid_to_base58 -from ..typing import OptionalMigratedTables, T, UuidModelType -from . import BaseMixin, Mapped, NoIdMixin, db, hybrid_property, postgresql, 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', @@ -133,19 +142,28 @@ '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 @@ -154,7 +172,7 @@ class NotificationCategory: priority_id: int title: str - available_for: Callable[[User], bool] + available_for: Callable[[Account], bool] #: Registry of notification categories @@ -216,17 +234,16 @@ 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: Mapped[Optional[str]] = immutable( + transactionid: Mapped[str | None] = immutable( sa.orm.mapped_column(sa.UnicodeText, unique=True, nullable=True) ) # The message itself @@ -237,14 +254,14 @@ class SmsMessage(PhoneNumberMixin, BaseMixin, db.Model): # type: ignore[name-de status: Mapped[int] = sa.orm.mapped_column( sa.Integer, default=SMS_STATUS.QUEUED, nullable=False ) - status_at: Mapped[Optional[datetime]] = sa.orm.mapped_column( + status_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) - fail_reason: Mapped[Optional[str]] = sa.orm.mapped_column( + 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) @@ -254,22 +271,22 @@ def __init__(self, **kwargs): # --- Notification models -------------------------------------------------------------- -class NotificationType(Protocol): +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: Any + document: _D document_uuid: UUID - fragment: Any - fragment_uuid: Optional[UUID] - user_id: Optional[int] - user: Optional[User] + fragment: _F | None + fragment_uuid: UUID | None + created_by_id: int | None + created_by: Account | None -class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] +class Notification(NoIdMixin, Model, Generic[_D, _F]): """ Holds a single notification for an activity on a document object. @@ -282,7 +299,6 @@ 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 @@ -317,14 +333,14 @@ class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] 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. @@ -339,7 +355,7 @@ class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] #: 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( @@ -347,13 +363,13 @@ class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] ) #: Id of user that triggered this notification - user_id: Mapped[Optional[int]] = sa.orm.mapped_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( @@ -363,7 +379,7 @@ class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] #: 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( + fragment_uuid: Mapped[UUID | None] = immutable( sa.orm.mapped_column(postgresql.UUID, nullable=True) ) @@ -379,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), ), ) @@ -453,13 +469,13 @@ class Notification(NoIdMixin, db.Model): # type: ignore[name-defined] ignore_transport_errors: ClassVar[bool] = False #: Registry of per-class renderers ``{cls_type: CustomNotificationView}`` - renderers: ClassVar[Dict[str, Type]] = {} + 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 @@ -467,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: @@ -486,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}") @@ -494,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) @@ -509,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 # type: ignore[no-redef] - 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 # type: ignore[no-redef] - def eventid_b58(cls): # 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. @@ -540,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. @@ -565,16 +628,16 @@ 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. @@ -587,23 +650,23 @@ def dispatch(self) -> Generator[UserNotification, None, None]: 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: @@ -614,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 @@ -637,15 +702,18 @@ class PreviewNotification(NotificationType): To be used with :class:`NotificationFor`:: - NotificationFor(PreviewNotification(NotificationType), user) + NotificationFor( + PreviewNotification(NotificationType, document, fragment, actor), + recipient + ) """ def __init__( # pylint: disable=super-init-not-called self, - cls: Type[Notification], - document: UuidModelType, - fragment: Optional[UuidModelType] = None, - user: Optional[User] = None, + cls: type[Notification], + document: UuidModelUnion, + fragment: UuidModelUnion | None = None, + user: Account | None = None, ) -> None: self.eventid = uuid4() self.id = uuid4() @@ -656,18 +724,18 @@ def __init__( # pylint: disable=super-init-not-called self.document_uuid = document.uuid self.fragment = fragment self.fragment_uuid = fragment.uuid if fragment is not None else None - self.user = user - self.user_id = cast(int, user.id) if user 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: @@ -687,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: """ @@ -728,35 +787,30 @@ 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( + 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 @@ -773,10 +827,8 @@ class UserNotification( ) #: 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'}, ) @@ -791,7 +843,7 @@ class UserNotification( ) #: Timestamp for when this notification was marked as read - read_at: Mapped[Optional[datetime]] = with_roles( + read_at: Mapped[datetime | None] = with_roles( sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), default=None, nullable=True), read={'owner'}, ) @@ -801,44 +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: Mapped[Optional[datetime]] = with_roles( + 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: Mapped[Optional[UUID]] = with_roles( + 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: Mapped[Optional[str]] = sa.orm.mapped_column( + messageid_email: Mapped[str | None] = sa.orm.mapped_column( sa.Unicode, nullable=True ) #: Message id for SMS delivery - messageid_sms: Mapped[Optional[str]] = sa.orm.mapped_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: Mapped[Optional[str]] = sa.orm.mapped_column( + messageid_webpush: Mapped[str | None] = sa.orm.mapped_column( sa.Unicode, nullable=True ) #: Message id for Telegram delivery - messageid_telegram: Mapped[Optional[str]] = sa.orm.mapped_column( + messageid_telegram: Mapped[str | None] = sa.orm.mapped_column( sa.Unicode, nullable=True ) #: Message id for WhatsApp delivery - messageid_whatsapp: Mapped[Optional[str]] = sa.orm.mapped_column( + 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', ), ) @@ -871,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 # type: ignore[no-redef] - 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 # type: ignore[no-redef] - def eventid_b58(cls): # 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) @@ -896,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 # type: ignore[no-redef] - 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 # type: ignore[no-redef] - def is_read(cls): # 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 # type: ignore[no-redef] - 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 # type: ignore[no-redef] - def is_revoked(cls): - 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: @@ -961,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. @@ -981,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 @@ -1013,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() ) @@ -1051,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 @@ -1092,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 @@ -1158,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 @@ -1189,22 +1244,21 @@ 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: Mapped[int] = sa.orm.mapped_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'}, ) @@ -1231,7 +1285,7 @@ class NotificationPreferences(BaseMixin, db.Model): # type: ignore[name-defined 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': { @@ -1245,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')' ) @@ -1266,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 @@ -1291,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() ), ) @@ -1306,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 @@ -1314,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: @@ -1334,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, @@ -1369,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, @@ -1388,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 4ec186219..000000000 --- a/funnel/models/organization_membership.py +++ /dev/null @@ -1,235 +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: Mapped[int] = sa.orm.mapped_column( - sa.Integer, - sa.ForeignKey('organization.id', ondelete='CASCADE'), - nullable=False, - ) - organization: Mapped[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: 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 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 0e6b5e9bf..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): # 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,7 +880,10 @@ 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 is not None and value != old_value and target.__phone_for__: if value.is_blocked: @@ -867,6 +893,12 @@ def _phone_number_mixin_set_validator( @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 2ddc63c6b..000000000 --- a/funnel/models/profile.py +++ /dev/null @@ -1,565 +0,0 @@ -"""Account (nee Profile) model, linked to a User or Organization model.""" - -from __future__ import annotations - -from typing import Any, Iterable, List, Optional, Union -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - -from sqlalchemy.sql import expression -from sqlalchemy.sql.expression import ColumnElement - -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_like, - valid_username, - visual_field_delimiter, -) -from .user import EnumerateMembershipsMixin, Organization, Team, 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): # 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): # 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: - """Retrieve title for this profile from the underlying User or Organization.""" - if self.user: - return self.user.fullname - if self.organization: - return self.organization.title - return '' - - @title.setter - def title(self, value: str) -> None: - """Set title of this profile on the underlying User or Organization.""" - 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): # pylint: disable=no-self-argument - """Retrieve title as a SQL expression.""" - 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: - """Return title and name in a format suitable for disambiguation.""" - 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: - """Identify roles for the given actor.""" - 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 name_is(cls, name: Any) -> ColumnElement: - """Generate query filter to check if name is matching (case insensitive).""" - return sa.func.lower(cls.name) == sa.func.lower(sa.func.replace(name, '-', '_')) - - @classmethod - def name_in(cls, names: Iterable[Any]) -> 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: Any) -> 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'\_')) - ) - - @classmethod - def get(cls, name: str) -> Optional[Profile]: - """Retrieve a Profile given a name.""" - return cls.query.filter(cls.name_is(name)).one_or_none() - - @classmethod - def all_public(cls) -> Query: - """Construct a query on Profile filtered by public state.""" - 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: - """Test if the candidate name is available for use as a Profile name.""" - return cls.validate_name_candidate(name) is None - - @sa.orm.validates('name') - def validate_name(self, key: str, value: str): - """Validate the value of Profile.name.""" - 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[Team]: - """Return all teams associated with this profile.""" - 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 prefix, for autocomplete UI. - - :param prefix: Letters to start matching with - """ - like_query = quote_autocomplete_like(prefix) - if not like_query or like_query == '@%': - return [] - if prefix.startswith('@'): - # Match only against `name` since ``@name...`` format is being used - return ( - cls.query.options(sa.orm.defer(cls.is_active)) - .filter(cls.name_like(like_query[1:])) - .order_by(cls.name) - .all() - ) - - return ( - cls.query.options(sa.orm.defer(cls.is_active)) - .join(User) - .filter( - User.state.ACTIVE, - sa.or_( - cls.name_like(like_query), - sa.func.lower(User.fullname).like(sa.func.lower(like_query)), - ), - ) - .union( - cls.query.options(sa.orm.defer(cls.is_active)) - .join(Organization) - .filter( - Organization.state.ACTIVE, - sa.or_( - cls.name_like(like_query), - sa.func.lower(Organization.title).like( - sa.func.lower(like_query) - ), - ), - ), - ) - .order_by(cls.name) - .all() - ) - - -add_search_trigger(Profile, 'search_vector') diff --git a/funnel/models/project.py b/funnel/models/project.py index 22d177eac..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( @@ -514,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. @@ -538,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] @@ -607,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)): @@ -662,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 @@ -674,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_is(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," @@ -726,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), ) @@ -799,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.""" @@ -901,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 5d658ed60..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, 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,22 +24,21 @@ '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 @@ -54,7 +50,7 @@ class ProjectCrewMembership( 'all': { 'read': { 'urls', - 'user', + 'member', 'project', 'is_editor', 'is_promoter', @@ -83,7 +79,7 @@ class ProjectCrewMembership( 'is_promoter', 'is_usher', 'label', - 'user', + 'member', 'project', }, 'without_parent': { @@ -94,7 +90,7 @@ class ProjectCrewMembership( 'is_promoter', 'is_usher', 'label', - 'user', + 'member', }, 'related': { 'urls', @@ -107,13 +103,13 @@ class ProjectCrewMembership( }, } - 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', @@ -129,18 +125,24 @@ 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.Column( + sa.orm.mapped_column( sa.Unicode, sa.CheckConstraint( "label <> ''", name='project_crew_membership_label_check' @@ -151,13 +153,13 @@ class ProjectCrewMembership( @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), @@ -170,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: @@ -185,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, ) @@ -275,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, ) @@ -291,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 b7dce1be4..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', @@ -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 ec40d4f3d..f6b34289c 100644 --- a/funnel/models/session.py +++ b/funnel/models/session.py @@ -4,13 +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 format_date, get_locale -from werkzeug.utils import cached_property - from isoweek import Week +from werkzeug.utils import cached_property from baseframe import localize_timezone from coaster.sqlalchemy import with_roles @@ -18,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 ) - 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') + 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 + ) + 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'"), ), @@ -134,6 +148,7 @@ class Session( 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -156,6 +171,7 @@ class Session( 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -171,6 +187,7 @@ class Session( 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -186,6 +203,7 @@ class Session( 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -193,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): # 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 @@ -216,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 @@ -240,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( @@ -252,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) @@ -268,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, ) @@ -282,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] @@ -307,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'}, @@ -333,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 @@ -342,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 @@ -351,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_( @@ -365,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, ), @@ -406,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() @@ -421,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. @@ -440,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, ) @@ -451,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. @@ -474,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, @@ -502,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 @@ -531,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: @@ -547,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') @@ -625,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) @@ -681,10 +703,10 @@ def calendar_weeks(self: Project, leading_weeks=True): # type: ignore[misc] @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 f093b50e5..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): # 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 e6dadaa20..2121e13dc 100644 --- a/funnel/models/site_membership.py +++ b/funnel/models/site_membership.py @@ -2,27 +2,20 @@ 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__ = { @@ -33,10 +26,10 @@ class SiteMembership( } __roles__ = { - 'subject': { + 'member': { 'read': { 'urls', - 'user', + 'member', 'is_comment_moderator', 'is_user_moderator', 'is_site_editor', @@ -76,7 +69,7 @@ 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), @@ -91,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. """ @@ -117,15 +110,15 @@ def offered_roles(self) -> Set[str]: 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, diff --git a/funnel/models/sponsor_membership.py b/funnel/models/sponsor_membership.py index 807780e9f..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,7 +38,7 @@ class ProjectSponsorMembership( # type: ignore[misc] 'read': { 'is_promoted', 'label', - 'profile', + 'member', 'project', 'seq', 'title', @@ -56,7 +52,7 @@ class ProjectSponsorMembership( # type: ignore[misc] '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..cc717b686 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 absolute_url(self) -> str | None: + return self.participant.absolute_url if self.participant else None - with_roles(profile_url, read={'all'}) + with_roles(absolute_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 c502feb05..000000000 --- a/funnel/models/user.py +++ /dev/null @@ -1,2042 +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): # 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(Profile.name_is(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] - Profile.name_in(usernames), - ) - ) - elif buids: - query = cls.query.filter(cls.buid.in_(buids)) # type: ignore[attr-defined] - elif usernames: - query = cls.query.join(Profile).filter(Profile.name_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 prefix, for autocomplete UI. - - Looks up users by fullname, username, 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 [] - - # 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)), - Profile.name_like(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, - Profile.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 -> 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(cls) -> sa.Select: # pylint: disable=no-self-argument - """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(Profile.name_is(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(Profile.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 7cf152b27..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,77 +102,100 @@ 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: @@ -174,7 +208,7 @@ def do_migrate_instances( 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): @@ -194,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}, @@ -208,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" @@ -246,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: @@ -272,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 981cf2423..f8816dca7 100644 --- a/funnel/models/video_mixin.py +++ b/funnel/models/video_mixin.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional, Tuple - from furl import furl from . import declarative_mixin, sa @@ -15,9 +13,9 @@ 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: Optional[str] = video_url + video_id: str | None = video_url parsed = furl(video_url) if not parsed.host: @@ -76,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 @@ -93,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/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 9140d0f48..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': @@ -133,7 +137,7 @@ def wrapper() -> ReturnResponse: 'trusted': trusted, 'f': f, } - return cast(WrappedFunc, wrapper) + return wrapper return decorator @@ -143,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): @@ -228,7 +232,7 @@ def __init__( 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 50fda1aad..ae1742ea1 100644 --- a/funnel/signals.py +++ b/funnel/signals.py @@ -24,17 +24,19 @@ 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') @@ -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/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 c1f16c099..9eefcb862 100644 --- a/funnel/static/js/schedules.js +++ b/funnel/static/js/schedules.js @@ -90,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) { 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 6b50a0449..d7cf48be3 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" @@ -36,11 +36,12 @@ {{ 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) }} +
@@ -109,14 +110,17 @@
-

{% 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") }} @@ -145,7 +149,7 @@
    -

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

    +

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

    - {{ useremail }} {% trans %}(pending verification){% endtrans %} + {{ useremail }} {% trans %}(pending verification){% endtrans %} {{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }} + class="mui--pull-right"> + {{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle") }} +
  2. {% endfor %}
@@ -196,7 +202,7 @@ {% endif %} {% trans %}Add an email address{% endtrans %} + href="{{ url_for('add_email') }}" data-cy="add-new-email">{% trans %}Add an email address{% endtrans %}
@@ -205,7 +211,7 @@
-

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

+

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

{{ 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 %} @@ -247,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 %}
@@ -258,7 +264,7 @@
-

+

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

@@ -306,7 +312,7 @@
-

+

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

@@ -316,14 +322,14 @@ {{ logout_form.hidden_tag() }}
    - {%- for user_session in current_auth.user.active_user_sessions %} + {%- for login_session in current_auth.user.active_login_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 %} + 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 form.recaptcha is defined %} + {%- 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 07d92bd29..382e9e75f 100644 --- a/funnel/templates/account_menu.html.jinja2 +++ b/funnel/templates/account_menu.html.jinja2 @@ -1,30 +1,22 @@ -{%- 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 %}
  • {%- 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,12 +57,12 @@ 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 %} 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..1a5aec647 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 3a960164f..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 %} @@ -24,14 +24,14 @@ {{ widget_ext_scripts(form) }} {% block innerscripts %} - + {%- if with_chrome -%} {{ ajaxform(ref_id=ref_id, request=request, force=true) }} {%- endif -%} - {%- if form and form.recaptcha is defined %} + {%- 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/badge.html.jinja2 b/funnel/templates/badge.html.jinja2 index 7b8c72def..102ff0a14 100644 --- a/funnel/templates/badge.html.jinja2 +++ b/funnel/templates/badge.html.jinja2 @@ -2,7 +2,7 @@ {% 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 6c398fdee..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|numberformat -%}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 40fdb3767..a7932a686 100644 --- a/funnel/templates/notifications/proposal_received_web.html.jinja2 +++ b/funnel/templates/notifications/proposal_received_web.html.jinja2 @@ -10,7 +10,7 @@

      {%- 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/organization_membership.html.jinja2 b/funnel/templates/organization_membership.html.jinja2 index 4873fed5a..f166d312a 100644 --- a/funnel/templates/organization_membership.html.jinja2 +++ b/funnel/templates/organization_membership.html.jinja2 @@ -3,14 +3,14 @@ {%- 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 baseheadline %} - {{ profile_header(profile, class="mui--hidden-xs mui--hidden-sm", current_page="admins", title=_("Admins")) }} + {{ profile_header(account, class="mui--hidden-xs mui--hidden-sm", current_page="admins", title=_("Admins")) }} {% endblock baseheadline %} {% block basecontent %} @@ -29,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 index a9ed26bdc..0a7a9a1a7 100644 --- a/funnel/templates/profile_layout.html.jinja2 +++ b/funnel/templates/profile_layout.html.jinja2 @@ -1,6 +1,247 @@ {% 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) %}
      @@ -24,12 +265,60 @@
      {{ profile.forms.transition().hidden_tag() }} - +
    {% 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="") %}
    @@ -44,7 +333,7 @@
    {%- if profile.banner_image_url.url %} - {{ profile.title }} + {{ profile.title }} {% else %} {{ profile.title }} {% endif %} @@ -57,7 +346,7 @@ {% endif %}
    @@ -111,12 +400,14 @@

    @{{ profile.name }}

      -
    • {{ faicon(icon='history') }} {% trans date=profile.owner.created_at|date(format='MMM YYYY')%}Joined {{ date }}{% endtrans %}
    • - {% if profile.website %} + {%- if profile.joined_at %} +
    • {{ faicon(icon='history') }} {% trans date=profile.joined_at|date(format='MMM YYYY')%}Joined {{ date }}{% endtrans %}
    • + {%- endif %} + {%- if profile.website %}
    • {{ faicon(icon='globe') }} {{ profile.website|cleanurl }}
    • - {% endif %} + {%- endif %}
    -
    {{ profile.description.html }}
    +
    {{ profile.description }}
    {% if profile.features.new_project() %} @@ -157,3 +448,33 @@ {% 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 @@ - + - + + + + + {% 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 -%} - + + diff --git a/funnel/templates/project_submissions.html.jinja2 b/funnel/templates/project_submissions.html.jinja2 index 7515d4e82..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 %} @@ -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 %} - + + + + diff --git a/funnel/templates/session_view_popup.html.jinja2 b/funnel/templates/session_view_popup.html.jinja2 index fdfa80df3..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 @@