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/pytest.yml b/.github/workflows/pytest.yml index 344f441e6..fc29e14a0 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -34,7 +34,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] # TODO: Figure out macos-latest and Docker - python-version: ['3.7', '3.11'] + python-version: ['3.11'] services: redis: @@ -77,11 +77,7 @@ jobs: path: ${{ env.pythonLocation }} key: ${{ matrix.os }}-${{ env.pythonLocation }}-${{ hashFiles('requirements/base.txt') }}-${{ hashFiles('requirements.txt/test.txt') }} - name: Install Python dependencies - if: ${{ matrix.python-version != '3.7' }} run: make install-python-test - - name: Install Python dependencies (3.7) - if: ${{ matrix.python-version == '3.7' }} - run: make install-python-test-37 - name: Install Node uses: actions/setup-node@v3 with: 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index fa4e1101b..d99918664 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,8 +2,8 @@ # 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', @@ -14,7 +14,7 @@ ci: ] 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 @@ -23,10 +23,11 @@ repos: - id: pip-compile-multi-verify files: ^requirements/.*\.(in|txt)$ - repo: https://github.com/pypa/pip-audit - rev: v2.6.0 + rev: v2.6.1 hooks: - id: pip-audit args: [ + '--disable-pip', '--no-deps', '--skip-editable', '-r', @@ -45,8 +46,13 @@ repos: 'PYSEC-2023-101', # https://github.com/pytest-dev/pytest-selenium/issues/310 ] files: ^requirements/.*\.txt$ + - repo: https://github.com/asottile/pyupgrade + rev: v3.13.0 + hooks: + - id: pyupgrade + args: ['--keep-runtime-typing', '--py310-plus'] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.278 + rev: v0.0.291 hooks: - id: ruff args: ['--fix', '--exit-non-zero-on-fix'] @@ -63,12 +69,6 @@ repos: '--remove-unused-variables', '--remove-duplicate-keys', ] - - repo: https://github.com/asottile/pyupgrade - rev: v3.9.0 - hooks: - - id: pyupgrade - args: - ['--keep-runtime-typing', '--py3-plus', '--py36-plus', '--py37-plus'] - repo: https://github.com/asottile/yesqa rev: v1.5.0 hooks: @@ -98,7 +98,7 @@ repos: additional_dependencies: - tomli - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black # Mypy is temporarily disabled until the SQLAlchemy 2.0 migration is complete @@ -122,12 +122,12 @@ repos: # - 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.0a7 hooks: - id: pylint args: [ @@ -180,8 +180,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 + rev: v3.0.3 hooks: - id: prettier args: diff --git a/.testenv b/.testenv index 28914c7de..f60567fe7 100644 --- a/.testenv +++ b/.testenv @@ -37,8 +37,8 @@ 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_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' 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/Makefile b/Makefile index baa3d003e..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" @@ -75,11 +75,6 @@ deps-python: deps-editable pip install --upgrade pip pip-tools pip-compile-multi pip-compile-multi --backtracking --use-cache -deps-python-37: deps-editable - # pip 23.2 breaks pip-tools 6, but pip-tools 7 doesn't support Python 3.7 - pip install --upgrade 'pip<23.2' pip-tools pip-compile-multi - pip-compile-multi --backtracking --use-cache -o py37.txt - deps-python-noup: pip-compile-multi --backtracking --use-cache --no-upgrade @@ -123,15 +118,6 @@ install-python-test: install-python-pip deps-editable install-python: install-python-pip deps-editable pip install --use-pep517 -r requirements/base.txt -install-python-dev-37: install-python-pip deps-editable - pip install --use-pep517 -r requirements/dev.py37.txt - -install-python-test-37: install-python-pip deps-editable - pip install --use-pep517 -r requirements/test.py37.txt - -install-python-37: install-python-pip deps-editable - pip install --use-pep517 -r requirements/base.py37.txt - install-dev: deps-editable install-python-dev install-npm assets install-test: deps-editable install-python-test install-npm assets diff --git a/README.rst b/README.rst index 631d0b080..31dad39ad 100644 --- a/README.rst +++ b/README.rst @@ -3,17 +3,15 @@ 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/funnel/__init__.py b/funnel/__init__.py index e912f1d61..eeef7cee0 100644 --- a/funnel/__init__.py +++ b/funnel/__init__.py @@ -136,6 +136,8 @@ # API it borrows from the Flask-Login extension 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) @@ -174,7 +176,7 @@ filters='rjsmin', ), ) -app.assets.register( +app.assets.register( # type: ignore[attr-defined] 'css_fullcalendar', Bundle( assets.require('jquery.fullcalendar.css', 'spectrum.css'), @@ -182,7 +184,7 @@ filters='cssmin', ), ) -app.assets.register( +app.assets.register( # type: ignore[attr-defined] 'js_schedules', Bundle( assets.require('schedules.js'), 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 ab4b826f7..5f6ac0347 100644 --- a/funnel/assets/js/notification_settings.js +++ b/funnel/assets/js/notification_settings.js @@ -29,7 +29,7 @@ $(() => { $('.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/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/utils/form_widgets.js b/funnel/assets/js/utils/form_widgets.js index 240d2c452..cf2a54466 100644 --- a/funnel/assets/js/utils/form_widgets.js +++ b/funnel/assets/js/utils/form_widgets.js @@ -121,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'); 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/ractive_util.js b/funnel/assets/js/utils/ractive_util.js index d34cae967..e0bf24a85 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 user.profile_url && addprofilelink }}{{#if user.logo_url }}{{else}}{{ getInitials(user.fullname) }}{{/if}}{{else}}{{#if user.logo_url }}{{else}}{{ getInitials(user.fullname) }}{{/if}}{{/if}}`, data: { addprofilelink: true, size: 'medium', getInitials: 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/vue_util.js b/funnel/assets/js/utils/vue_util.js index 21ba426c3..f4078556c 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 b2f1188dc..ad53ee2f1 100644 --- a/funnel/assets/sass/base/_utils.scss +++ b/funnel/assets/sass/base/_utils.scss @@ -243,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 63da0a41e..879bf8f80 100644 --- a/funnel/assets/sass/components/_card.scss +++ b/funnel/assets/sass/components/_card.scss @@ -59,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, @@ -370,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; @@ -393,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; @@ -431,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/_ticket-modal.scss b/funnel/assets/sass/components/_ticket-modal.scss index 610772eb1..6da18efba 100644 --- a/funnel/assets/sass/components/_ticket-modal.scss +++ b/funnel/assets/sass/components/_ticket-modal.scss @@ -68,7 +68,7 @@ } .price-btn { - min-width: 150px; + min-width: 200px; font-size: inherit; padding: 0; display: flex; diff --git a/funnel/assets/sass/form.scss b/funnel/assets/sass/form.scss index efdd069c7..fc5b85a84 100644 --- a/funnel/assets/sass/form.scss +++ b/funnel/assets/sass/form.scss @@ -467,3 +467,23 @@ 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/pages/index.scss b/funnel/assets/sass/pages/index.scss index dc2d373d2..522672d30 100644 --- a/funnel/assets/sass/pages/index.scss +++ b/funnel/assets/sass/pages/index.scss @@ -43,9 +43,8 @@ } } -@media (min-width: 768px) { +@media (min-width: 992px) { .spotlight-container { - padding: 0 40px; .spotlight-container__details { margin-top: 40px; } diff --git a/funnel/assets/sass/pages/project.scss b/funnel/assets/sass/pages/project.scss index 7d86f65b5..9bb8650d9 100644 --- a/funnel/assets/sass/pages/project.scss +++ b/funnel/assets/sass/pages/project.scss @@ -93,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); @@ -134,6 +137,9 @@ display: none; } } + .register-block__btn.mui--is-disabled { + border-color: inherit; + } } .register-block__content--half { .register-block__content__rsvp-txt { @@ -257,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; @@ -272,6 +279,9 @@ object-fit: cover; } } + .project-banner__profile-details__badge { + margin-left: auto; + } } .project-banner__profile-details--center { @@ -579,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 af218a8af..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 { 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 25d0cc438..978f03850 100644 --- a/funnel/cli/geodata.py +++ b/funnel/cli/geodata.py @@ -10,7 +10,6 @@ from dataclasses import dataclass from datetime import datetime from decimal import Decimal -from typing import Optional from urllib.parse import urljoin import click @@ -110,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 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/mnrl.py b/funnel/cli/periodic/mnrl.py index 6b1827d4e..32ed7289f 100644 --- a/funnel/cli/periodic/mnrl.py +++ b/funnel/cli/periodic/mnrl.py @@ -37,7 +37,6 @@ """ import asyncio -from typing import List, Set, Tuple import click import httpx @@ -46,7 +45,7 @@ from rich.progress import Progress from ... import app -from ...models import PhoneNumber, UserPhone, db +from ...models import AccountPhone, PhoneNumber, db from . import periodic @@ -84,13 +83,13 @@ async def read(self, size: int) -> bytes: return b'' -async def get_existing_phone_numbers(prefix: str) -> Set[str]: +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]: +async def get_mnrl_json_file_list(apikey: str) -> list[str]: """ Return filenames for the currently published MNRL JSON files. @@ -117,7 +116,7 @@ async def get_mnrl_json_file_list(apikey: str) -> List[str]: async def get_mnrl_json_file_numbers( client: httpx.AsyncClient, apikey: str, filename: str -) -> Tuple[str, Set[str]]: +) -> tuple[str, set[str]]: """Return phone numbers from an MNRL JSON file URL.""" async with client.stream( 'GET', @@ -136,20 +135,20 @@ async def get_mnrl_json_file_numbers( } -async def forget_phone_numbers(phone_numbers: Set[str], prefix: str) -> 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 = UserPhone.get(number) + userphone = AccountPhone.get(number) if userphone is not None: - # TODO: Dispatch a notification to userphone.user, but since the + # 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.user.pickername}") + 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?) @@ -166,20 +165,20 @@ async def forget_phone_numbers(phone_numbers: Set[str], prefix: str) -> None: async def process_mnrl_files( apikey: str, - existing_phone_numbers: Set[str], + existing_phone_numbers: set[str], phone_prefix: str, - mnrl_filenames: List[str], -) -> Tuple[Set[str], int, int]: + 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() + revoked_phone_numbers: set[str] = set() mnrl_total_count = 0 failures = 0 - async_tasks: Set[asyncio.Task] = set() + 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) diff --git a/funnel/cli/periodic/stats.py b/funnel/cli/periodic/stats.py index 6ba0ccb1d..a36d783a4 100644 --- a/funnel/cli/periodic/stats.py +++ b/funnel/cli/periodic/stats.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio +from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime -from typing import Dict, Optional, Sequence, Union, cast, overload -from typing_extensions import Literal +from typing import Literal, cast, overload from urllib.parse import unquote import click @@ -80,10 +80,10 @@ class MatomoResponse(DataClassJsonMixin): nb_visits: int = 0 nb_uniq_visitors: int = 0 nb_users: int = 0 - url: Optional[str] = None + url: str | None = None segment: str = '' - def get_url(self) -> Optional[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 @@ -109,9 +109,9 @@ class MatomoData: referrers: Sequence[MatomoResponse] socials: Sequence[MatomoResponse] pages: Sequence[MatomoResponse] - visits_day: Optional[MatomoResponse] = None - visits_week: Optional[MatomoResponse] = None - visits_month: Optional[MatomoResponse] = None + visits_day: MatomoResponse | None = None + visits_week: MatomoResponse | None = None + visits_month: MatomoResponse | None = None # --- Matomo analytics ----------------------------------------------------------------- @@ -127,13 +127,13 @@ async def matomo_response_json( @overload async def matomo_response_json( client: httpx.AsyncClient, url: str, sequence: Literal[False] -) -> Optional[MatomoResponse]: +) -> MatomoResponse | None: ... async def matomo_response_json( client: httpx.AsyncClient, url: str, sequence: bool = True -) -> Union[Optional[MatomoResponse], Sequence[MatomoResponse]]: +) -> MatomoResponse | Sequence[MatomoResponse] | None: """Process Matomo's JSON response.""" try: response = await client.get(url, timeout=30) @@ -247,27 +247,27 @@ async def matomo_stats() -> MatomoData: # --- Internal database analytics ------------------------------------------------------ -def data_sources() -> Dict[str, DataSource]: +def data_sources() -> dict[str, DataSource]: """Return sources for daily stats report.""" return { - # `user_sessions`, `app_user_sessions` and `returning_users` (added below) are + # `login_sessions`, `app_login_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, + 'login_sessions': DataSource( + models.LoginSession.query.distinct(models.LoginSession.account_id), + models.LoginSession.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) + '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_user_session.c.user_session_id - == models.UserSession.id + models.auth_client_login_session.c.login_session_id + == models.LoginSession.id ), - cast(Mapped[datetime], models.auth_client_user_session.c.accessed_at), + cast(Mapped[datetime], models.auth_client_login_session.c.accessed_at), ), "New users": DataSource( - models.User.query.filter(models.User.state.ACTIVE), - models.User.created_at, + 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 @@ -281,7 +281,7 @@ def data_sources() -> Dict[str, DataSource]: } -async def user_stats() -> Dict[str, ResourceStats]: +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']) @@ -296,7 +296,7 @@ async def user_stats() -> Dict[str, ResourceStats]: last_month = today - relativedelta(months=1) two_months_ago = today - relativedelta(months=2) - stats: Dict[str, ResourceStats] = { + stats: dict[str, ResourceStats] = { key: ResourceStats( day=ds.basequery.filter( ds.datecolumn >= yesterday, ds.datecolumn < today @@ -327,41 +327,41 @@ async def user_stats() -> Dict[str, ResourceStats]: { 'returning_users': ResourceStats( # User from day before was active yesterday - day=models.UserSession.query.join(models.User) + day=models.LoginSession.query.join(models.Account) .filter( - models.UserSession.accessed_at >= yesterday, - models.UserSession.accessed_at < today, - models.User.created_at >= two_days_ago, - models.User.created_at < yesterday, + models.LoginSession.accessed_at >= yesterday, + models.LoginSession.accessed_at < today, + models.Account.created_at >= two_days_ago, + models.Account.created_at < yesterday, ) - .distinct(models.UserSession.user_id) + .distinct(models.LoginSession.account_id) .count(), # User from last week was active this week - week=models.UserSession.query.join(models.User) + week=models.LoginSession.query.join(models.Account) .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, + 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.UserSession.user_id) + .distinct(models.LoginSession.account_id) .count(), # User from last month was active this month - month=models.UserSession.query.join(models.User) + month=models.LoginSession.query.join(models.Account) .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, + 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.UserSession.user_id) + .distinct(models.LoginSession.account_id) .count(), ) } ) for key in stats: - if key not in ('user_sessions', 'app_user_sessions', 'returning_users'): + if key not in ('login_sessions', 'app_login_sessions', 'returning_users'): stats[key].set_trend_symbols() return stats @@ -399,29 +399,29 @@ async def dailystats() -> None: if matomo_data.visits_day: message += f' {matomo_data.visits_day.nb_uniq_visitors}' message += ( - f" → {user_data['user_sessions'].day}" - f" ↝ {user_data['app_user_sessions'].day}" + 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['user_sessions'].week}" - f" ↝ {user_data['app_user_sessions'].week}" + 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['user_sessions'].month}" - f" ↝ {user_data['app_user_sessions'].month}" + 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 ('user_sessions', 'app_user_sessions', 'returning_users'): + 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," diff --git a/funnel/cli/refresh/markdown.py b/funnel/cli/refresh/markdown.py index 2ee1435f0..17e9d0086 100644 --- a/funnel/cli/refresh/markdown.py +++ b/funnel/cli/refresh/markdown.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import ClassVar, Dict, Generic, Iterable, List, Optional, Set, Type, TypeVar +from collections.abc import Iterable +from typing import ClassVar, Generic, TypeVar import click import rich.progress @@ -17,27 +18,27 @@ 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: Type[_M], fields: Set[str]) -> None: + 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: Type[_M], fields: Set[str]) -> None: + 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: Optional[_M] = None) -> 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 @@ -81,7 +82,7 @@ def reparse(self, config: Optional[str] = None, obj: Optional[_M] = None) -> Non 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'}) @@ -113,7 +114,7 @@ def reparse(self, config: Optional[str] = None, obj: Optional[_M] = None) -> Non 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 9bf43d885..21cdb251f 100644 --- a/funnel/devtest.py +++ b/funnel/devtest.py @@ -11,8 +11,9 @@ import socket import time import weakref +from collections.abc import Callable, Iterable from secrets import token_urlsafe -from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Tuple +from typing import Any, NamedTuple from typing_extensions import Protocol from flask import Flask @@ -66,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 } @@ -114,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: @@ -176,8 +177,8 @@ def _prepare_subprocess( mock_transports: bool, calls: CapturedCalls, worker: Callable, - args: Tuple[Any], - kwargs: Dict[str, Any], + args: tuple[Any], + kwargs: dict[str, Any], ) -> Any: """ Prepare a subprocess for hosting a worker. @@ -195,11 +196,12 @@ def _prepare_subprocess( 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, @@ -224,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) @@ -247,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, @@ -262,7 +264,7 @@ def __init__( self.timeout = timeout self.clean_stop = clean_stop self.daemon = daemon - self._process: Optional[multiprocessing.context.ForkProcess] = None + self._process: multiprocessing.context.ForkProcess | None = None self.mock_transports = mock_transports manager = mpcontext.Manager() @@ -321,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 diff --git a/funnel/forms/account.py b/funnel/forms/account.py index 17bca3955..027368c5d 100644 --- a/funnel/forms/account.py +++ b/funnel/forms/account.py @@ -1,9 +1,9 @@ -"""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 import requests from flask import url_for @@ -17,9 +17,8 @@ MODERATOR_REPORT_TYPE, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, + Account, Anchor, - Profile, - User, 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"), @@ -177,16 +175,16 @@ def validate_password(self, field: forms.Field) -> None: 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( @@ -205,13 +203,13 @@ def validate_password(self, field: forms.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: forms.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"), @@ -248,14 +246,14 @@ def validate_username(self, field: forms.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 @@ -330,14 +328,14 @@ def validate_username(self, field: forms.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"), @@ -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/", @@ -444,15 +442,15 @@ class AccountForm(forms.Form): 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"), @@ -490,9 +488,9 @@ class UsernameAvailableForm(forms.Form): 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) @@ -514,12 +512,12 @@ def set_queries(self) -> None: ).format(url=url_for('notification_preferences')) -@User.forms('email_add') +@Account.forms('email_add') class NewEmailAddressForm(EnableNotificationsDescriptionMixin, forms.RecaptchaForm): - """Form to add a new email address to a user account.""" + """Form to add a new email address to an account.""" __expects__ = ('edit_user',) - edit_user: User + edit_user: Account email = forms.EmailField( __("Email address"), @@ -545,7 +543,7 @@ class NewEmailAddressForm(EnableNotificationsDescriptionMixin, forms.RecaptchaFo ) -@User.forms('email_primary') +@Account.forms('email_primary') class EmailPrimaryForm(forms.Form): """Form to mark an email address as a user's primary.""" @@ -561,12 +559,12 @@ class EmailPrimaryForm(forms.Form): ) -@User.forms('phone_add') +@Account.forms('phone_add') class NewPhoneForm(EnableNotificationsDescriptionMixin, forms.RecaptchaForm): - """Form to add a new mobile number (SMS-capable) to a user account.""" + """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"), @@ -591,7 +589,7 @@ class NewPhoneForm(EnableNotificationsDescriptionMixin, 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 fcf6cfc68..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( @@ -108,8 +102,7 @@ class AuthClientForm(forms.Form): 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: forms.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).""" @@ -169,7 +161,7 @@ def permission_validator(form: forms.Form, field: forms.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: forms.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 ffb06fac4..1a696c4f2 100644 --- a/funnel/forms/helpers.py +++ b/funnel/forms/helpers.py @@ -3,8 +3,8 @@ from __future__ import annotations import json -from typing import Optional, Sequence, Union -from typing_extensions import Literal +from collections.abc import Sequence +from typing import Literal from flask import flash @@ -13,11 +13,11 @@ from .. import app from ..models import ( + Account, + AccountEmailClaim, EmailAddress, PhoneNumber, - Profile, User, - UserEmailClaim, canonical_phone_number, parse_phone_number, parse_video_url, @@ -37,10 +37,10 @@ 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] # type: ignore[assignment] + data: Account | None # type: ignore[assignment] widget = forms.Select2Widget() multiple = False widget_autocomplete = True @@ -54,11 +54,11 @@ def _value(self) -> str: 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 @@ -83,7 +83,7 @@ def __init__(self, purpose: Literal['use', 'claim', 'register']) -> None: def __call__(self, form: forms.Form, field: forms.Field) -> None: # Get actor (from form, or current_auth.actor) - actor: Optional[User] = None + actor: User | None = None if hasattr(form, 'edit_user'): actor = form.edit_user if actor is None: @@ -150,7 +150,7 @@ def __call__(self, form: forms.Form, field: forms.Field) -> None: 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" @@ -174,7 +174,7 @@ def __init__(self, purpose: Literal['use', 'claim', 'register']) -> None: def __call__(self, form: forms.Form, field: forms.Field) -> None: # Get actor (from existing obj, or current_auth.actor) - actor: Optional[User] = None + actor: User | None = None if hasattr(form, 'edit_user'): actor = form.edit_user if actor is None: @@ -271,7 +271,7 @@ def tostr(value: object) -> str: return '' -def format_json(data: Union[dict, str, None]) -> str: +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): diff --git a/funnel/forms/login.py b/funnel/forms/login.py index 892c93c87..a4111157d 100644 --- a/funnel/forms/login.py +++ b/funnel/forms/login.py @@ -2,21 +2,22 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING 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, @@ -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"), @@ -246,14 +247,14 @@ def validate_password(self, field: forms.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` @@ -264,10 +265,10 @@ class LogoutForm(forms.Form): 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): diff --git a/funnel/forms/membership.py b/funnel/forms/membership.py index c48a5a899..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.""" @@ -81,7 +81,7 @@ def validate(self, *args, **kwargs) -> bool: 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 f384b5c95..cb505621b 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,7 +43,7 @@ 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/", @@ -52,7 +52,7 @@ class OrganizationForm(forms.Form): 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': @@ -66,7 +66,10 @@ def validate_name(self, field: forms.Field) -> None: # from existing name, or has only changed case. 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 612023475..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,7 +60,7 @@ 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) -> None: """Customise form for a user account.""" @@ -80,11 +79,11 @@ def make_for_user(self) -> None: ) -@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 f25f779ce..546bd969a 100644 --- a/funnel/forms/project.py +++ b/funnel/forms/project.py @@ -3,15 +3,14 @@ from __future__ import annotations import re -from typing import Optional 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, @@ -42,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"), @@ -127,7 +126,7 @@ def validate_location(self, field: forms.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 @@ -177,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( @@ -212,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"), @@ -231,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') @@ -304,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', @@ -359,7 +362,7 @@ class ProjectRegisterForm(forms.Form): """Register for a project with an optional custom JSON form.""" __expects__ = ('schema',) - schema: Optional[dict] + schema: dict | None form = forms.TextAreaField( __("Form"), diff --git a/funnel/forms/proposal.py b/funnel/forms/proposal.py index 5672b8734..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 @@ -213,7 +211,7 @@ class ProposalMemberForm(forms.Form): 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 @@ -244,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 19eed618b..fae507f95 100644 --- a/funnel/forms/sync_ticket.py +++ b/funnel/forms/sync_ticket.py @@ -3,19 +3,18 @@ from __future__ import annotations import json -from typing import Optional -from flask import Markup +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 @@ -77,6 +76,10 @@ class ProjectBoxofficeForm(forms.Form): __("Paid tickets are for a subscription"), default=True, ) + 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()], @@ -162,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( @@ -217,9 +220,9 @@ def validate(self, *args, **kwargs) -> bool: """Validate form.""" result = super().validate(*args, **kwargs) 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/geoip.py b/funnel/geoip.py index f8612824b..0c15a5854 100644 --- a/funnel/geoip.py +++ b/funnel/geoip.py @@ -2,7 +2,6 @@ import os.path from dataclasses import dataclass -from typing import Optional from flask import Flask from geoip2.database import Reader @@ -16,18 +15,18 @@ class GeoIP: """Wrapper for GeoIP2 Reader.""" - city_db: Optional[Reader] = None - asn_db: Optional[Reader] = None + 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) -> Optional[City]: + def city(self, ipaddr: str) -> City | None: if self.city_db: return self.city_db.city(ipaddr) return None - def asn(self, ipaddr: str) -> Optional[ASN]: + def asn(self, ipaddr: str) -> ASN | None: if self.asn_db: return self.asn_db.asn(ipaddr) return None diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index d8743e3b7..eb243c69d 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -23,15 +23,16 @@ ModelBase, NoIdMixin, Query, + QueryProperty, RegistryMixin, RoleMixin, TimestampMixin, UrlType, UuidMixin, + backref, relationship, with_roles, ) -from coaster.sqlalchemy.model import QueryProperty class Model(ModelBase, DeclarativeBase): @@ -60,9 +61,9 @@ class GeonameModel(ModelBase, DeclarativeBase): # 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 @@ -72,7 +73,6 @@ class GeonameModel(ModelBase, DeclarativeBase): 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 @@ -84,7 +84,7 @@ class GeonameModel(ModelBase, DeclarativeBase): 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 diff --git a/funnel/models/account.py b/funnel/models/account.py new file mode 100644 index 000000000..b8c77428a --- /dev/null +++ b/funnel/models/account.py @@ -0,0 +1,2190 @@ +"""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: str) -> sa.ColumnElement[bool]: # type: ignore[override] + """Return an expression for column == other.""" + return self.__clause_element__() == UUID(bytes=zbase32_decode(other)) + + +class Account(UuidMixin, BaseMixin, Model): + """Account model.""" + + __tablename__ = 'account' + # Name has a length limit 63 to fit DNS label limit + __name_length__ = 63 + # Titles can be longer + __title_length__ = 80 + + __active_membership_attrs__: ClassVar[set[str]] = set() + __noninvite_membership_attrs__: ClassVar[set[str]] = set() + + # Helper flags (see subclasses) + is_user_profile: ClassVar[bool] = False + is_organization_profile: ClassVar[bool] = False + is_placeholder_profile: ClassVar[bool] = False + + reserved_names: ClassVar[set[str]] = RESERVED_NAMES + + type_: Mapped[str] = sa.orm.mapped_column('type', sa.CHAR(1), nullable=False) + + #: Join date for users and organizations (skipped for placeholders) + joined_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + + #: The optional "username", used in the URL stub, with a unique constraint on the + #: lowercase value (defined in __table_args__ below) + name: Mapped[str | None] = with_roles( + sa.orm.mapped_column( + sa.Unicode(__name_length__), + sa.CheckConstraint("name <> ''"), + nullable=True, + ), + read={'all'}, + ) + + #: The account's title (user's fullname) + title: Mapped[str] = with_roles( + sa.orm.mapped_column(sa.Unicode(__title_length__), default='', nullable=False), + read={'all'}, + ) + #: Alias title as user's fullname + fullname: Mapped[str] = sa.orm.synonym('title') + #: Alias name as user's username + username: Mapped[str] = sa.orm.synonym('name') + + #: Argon2 or Bcrypt hash of the user's password + pw_hash: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode, nullable=True) + #: Timestamp for when the user's password last changed + pw_set_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + #: Expiry date for the password (to prompt user to reset it) + pw_expires_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) + #: User's preferred/last known timezone + timezone: Mapped[BaseTzInfo | None] = with_roles( + sa.orm.mapped_column(TimezoneType(backend='pytz'), nullable=True), + read={'owner'}, + ) + #: Update timezone automatically from browser activity + auto_timezone: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, default=True, nullable=False + ) + #: User's preferred/last known locale + locale: Mapped[Locale | None] = with_roles( + sa.orm.mapped_column(LocaleType, nullable=True), read={'owner'} + ) + #: Update locale automatically from browser activity + auto_locale: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, default=True, nullable=False + ) + #: User's state code (active, suspended, merged, deleted) + _state: Mapped[int] = sa.orm.mapped_column( + 'state', + sa.SmallInteger, + StateManager.check_constraint('state', ACCOUNT_STATE), + nullable=False, + default=ACCOUNT_STATE.ACTIVE, + ) + #: Account state manager + state = StateManager('_state', ACCOUNT_STATE, doc="Account state") + #: Other accounts that were merged into this account + old_accounts: AssociationProxy[list[Account]] = association_proxy( + 'oldids', 'old_account' + ) + + _profile_state: Mapped[int] = sa.orm.mapped_column( + 'profile_state', + sa.SmallInteger, + StateManager.check_constraint('profile_state', PROFILE_STATE), + nullable=False, + default=PROFILE_STATE.AUTO, + ) + profile_state = StateManager( + '_profile_state', PROFILE_STATE, doc="Current state of the account profile" + ) + + tagline: Mapped[str | None] = sa.orm.mapped_column( + sa.Unicode, sa.CheckConstraint("tagline <> ''"), nullable=True + ) + description, description_text, description_html = MarkdownCompositeDocument.create( + 'description', default='', nullable=False + ) + website: Mapped[furl | None] = sa.orm.mapped_column( + UrlType, sa.CheckConstraint("website <> ''"), nullable=True + ) + logo_url: Mapped[furl | None] = sa.orm.mapped_column( + ImgeeType, sa.CheckConstraint("logo_url <> ''"), nullable=True + ) + banner_image_url: Mapped[furl | None] = sa.orm.mapped_column( + ImgeeType, sa.CheckConstraint("banner_image_url <> ''"), nullable=True + ) + + # These two flags are read-only. There is no provision for writing to them within + # the app: + + #: Protected accounts cannot be deleted + is_protected: Mapped[bool] = with_roles( + immutable(sa.orm.mapped_column(sa.Boolean, default=False, nullable=False)), + read={'owner', 'admin'}, + ) + #: Verified accounts get listed on the home page and are not considered throwaway + #: accounts for spam control. There are no other privileges at this time + is_verified: Mapped[bool] = with_roles( + immutable( + sa.orm.mapped_column(sa.Boolean, default=False, nullable=False, index=True) + ), + read={'all'}, + ) + + #: Revision number maintained by SQLAlchemy, starting at 1 + revisionid: Mapped[int] = with_roles( + sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + ) + + search_vector: Mapped[str] = sa.orm.mapped_column( + TSVectorType( + 'title', + 'name', + 'tagline', + 'description_text', + weights={ + 'title': 'A', + 'name': 'A', + 'tagline': 'B', + 'description_text': 'B', + }, + regconfig='english', + hltext=lambda: sa.func.concat_ws( + visual_field_delimiter, + Account.title, + Account.name, + Account.tagline, + Account.description_html, + ), + ), + nullable=False, + deferred=True, + ) + + name_vector: Mapped[str] = sa.orm.mapped_column( + TSVectorType( + 'title', + 'name', + regconfig='simple', + hltext=lambda: sa.func.concat_ws(' @', Account.title, Account.name), + ), + nullable=False, + deferred=True, + ) + + __table_args__ = ( + sa.Index( + 'ix_account_name_lower', + sa.func.lower(name).label('name_lower'), + unique=True, + postgresql_ops={'name_lower': 'varchar_pattern_ops'}, + ), + sa.Index( + 'ix_account_title_lower', + sa.func.lower(title).label('title_lower'), + postgresql_ops={'title_lower': 'varchar_pattern_ops'}, + ), + sa.Index('ix_account_search_vector', 'search_vector', postgresql_using='gin'), + sa.Index('ix_account_name_vector', 'name_vector', postgresql_using='gin'), + ) + + __mapper_args__ = { + # 'polymorphic_identity' from subclasses is stored in the type column + 'polymorphic_on': type_, + # When querying the Account model, cast automatically to all subclasses + 'with_polymorphic': '*', + 'version_id_col': revisionid, + } + + __roles__ = { + 'all': { + 'read': { + 'uuid', + 'name', + 'urlname', + 'title', + 'fullname', + 'username', + 'pickername', + 'timezone', + 'description', + 'website', + 'logo_url', + 'banner_image_url', + 'joined_at', + 'profile_url', + 'urls', + 'is_user_profile', + 'is_organization_profile', + 'is_placeholder_profile', + }, + 'call': {'views', 'forms', 'features', 'url_for', 'state', 'profile_state'}, + } + } + + __datasets__ = { + 'primary': { + 'urls', + 'uuid_b58', + 'name', + 'urlname', + 'title', + 'fullname', + 'username', + 'pickername', + 'timezone', + 'description', + 'logo_url', + 'website', + 'joined_at', + 'profile_url', + 'is_verified', + }, + 'related': { + 'urls', + 'uuid_b58', + 'name', + 'urlname', + 'title', + 'fullname', + 'username', + 'pickername', + 'timezone', + 'description', + 'logo_url', + 'joined_at', + 'profile_url', + 'is_verified', + }, + } + + profile_state.add_conditional_state( + 'ACTIVE_AND_PUBLIC', + profile_state.PUBLIC, + lambda account: bool(account.state.ACTIVE), + ) + + @classmethod + def _defercols(cls) -> list[sa.orm.interfaces.LoaderOption]: + """Return columns that are typically deferred when loading a user.""" + defer = sa.orm.defer + return [ + defer(cls.created_at), + defer(cls.updated_at), + defer(cls.pw_hash), + defer(cls.pw_set_at), + defer(cls.pw_expires_at), + defer(cls.timezone), + ] + + @classmethod + def type_filter(cls) -> sa.ColumnElement[bool]: + """Return filter for the subclass's type.""" + return cls.type_ == cls.__mapper_args__.get('polymorphic_identity') + + primary_email: Mapped[AccountEmail | None] = relationship() + primary_phone: Mapped[AccountPhone | None] = relationship() + + def __repr__(self) -> str: + if self.name: + return f'<{self.__class__.__name__} {self.title} @{self.name}>' + return f'<{self.__class__.__name__} {self.title}>' + + def __str__(self) -> str: + """Return picker name for account.""" + return self.pickername + + def __format__(self, format_spec: str) -> str: + if not format_spec: + return self.pickername + return self.pickername.__format__(format_spec) + + @property + def pickername(self) -> str: + """Return title and @name in a format suitable for identification.""" + if self.name: + return f'{self.title} (@{self.name})' + return self.title + + with_roles(pickername, read={'all'}) + + def roles_for( + self, actor: Account | None = None, anchors: Iterable = () + ) -> LazyRoleSet: + """Identify roles for the given actor.""" + roles = super().roles_for(actor, anchors) + if self.profile_state.ACTIVE_AND_PUBLIC: + roles.add('reader') + return roles + + @cached_property + def verified_contact_count(self) -> int: + """Count of verified contact details.""" + return len(self.emails) + len(self.phones) + + @property + def has_verified_contact_info(self) -> bool: + """User has any verified contact info (email or phone).""" + return bool(self.emails) or bool(self.phones) + + @property + def has_contact_info(self) -> bool: + """User has any contact information (including unverified).""" + return self.has_verified_contact_info or bool(self.emailclaims) + + def merged_account(self) -> Account: + """Return the account that this account was merged into (default: self).""" + if self.state.MERGED: + # If our state is MERGED, there _must_ be a corresponding AccountOldId + # record + return cast(AccountOldId, AccountOldId.get(self.uuid)).account + return self + + def _set_password(self, password: str | None): + """Set a password (write-only property).""" + if password is None: + self.pw_hash = None + else: + self.pw_hash = argon2.hash(password) + # Also see :meth:`password_is` for transparent upgrade + self.pw_set_at = sa.func.utcnow() + # Expire passwords after one year. TODO: make this configurable + self.pw_expires_at = self.pw_set_at + timedelta(days=365) + + #: Write-only property (passwords cannot be read back in plain text) + password = property(fset=_set_password, doc=_set_password.__doc__) + + def password_has_expired(self) -> bool: + """Verify if password expiry timestamp has passed.""" + return ( + self.pw_hash is not None + and self.pw_expires_at is not None + and self.pw_expires_at <= utcnow() + ) + + def password_is(self, password: str, upgrade_hash: bool = False) -> bool: + """Test if the candidate password matches saved hash.""" + if self.pw_hash is None: + return False + + # Passwords may use the current Argon2 scheme or the older Bcrypt scheme. + # Bcrypt passwords are transparently upgraded if requested. + if argon2.identify(self.pw_hash): + return argon2.verify(password, self.pw_hash) + if bcrypt.identify(self.pw_hash): + verified = bcrypt.verify(password, self.pw_hash) + if verified and upgrade_hash: + self.pw_hash = argon2.hash(password) + return verified + return False + + def add_email( + self, + email: str, + primary: bool = False, + private: bool = False, + ) -> AccountEmail: + """Add an email address (assumed to be verified).""" + accountemail = AccountEmail(account=self, email=email, private=private) + accountemail = cast( + AccountEmail, + failsafe_add( + db.session, + accountemail, + account=self, + email_address=accountemail.email_address, + ), + ) + if primary: + self.primary_email = accountemail + return accountemail + # FIXME: This should remove competing instances of AccountEmailClaim + + def del_email(self, email: str) -> None: + """Remove an email address from the user's account.""" + accountemail = AccountEmail.get_for(account=self, email=email) + if accountemail is not None: + if self.primary_email in (accountemail, None): + self.primary_email = ( + AccountEmail.query.filter( + AccountEmail.account == self, AccountEmail.id != accountemail.id + ) + .order_by(AccountEmail.created_at.desc()) + .first() + ) + db.session.delete(accountemail) + + @property + def email(self) -> Literal[''] | AccountEmail: + """Return primary email address for user.""" + # Look for a primary address + accountemail = self.primary_email + if accountemail is not None: + return accountemail + # No primary? Maybe there's one that's not set as primary? + if self.emails: + accountemail = self.emails[0] + # XXX: Mark as primary. This may or may not be saved depending on + # whether the request ended in a database commit. + self.primary_email = accountemail + return accountemail + # This user has no email address. Return a blank string instead of None + # to support the common use case, where the caller will use str(user.email) + # to get the email address as a string. + return '' + + with_roles(email, read={'owner'}) + + def add_phone( + self, + phone: str, + primary: bool = False, + private: bool = False, + ) -> AccountPhone: + """Add a phone number (assumed to be verified).""" + accountphone = AccountPhone(account=self, phone=phone, private=private) + accountphone = cast( + AccountPhone, + failsafe_add( + db.session, + accountphone, + account=self, + phone_number=accountphone.phone_number, + ), + ) + if primary: + self.primary_phone = accountphone + return accountphone + + def del_phone(self, phone: str) -> None: + """Remove a phone number from the user's account.""" + accountphone = AccountPhone.get_for(account=self, phone=phone) + if accountphone is not None: + if self.primary_phone in (accountphone, None): + self.primary_phone = ( + AccountPhone.query.filter( + AccountPhone.account == self, AccountPhone.id != accountphone.id + ) + .order_by(AccountPhone.created_at.desc()) + .first() + ) + db.session.delete(accountphone) + + @property + def phone(self) -> Literal[''] | AccountPhone: + """Return primary phone number for user.""" + # Look for a primary phone number + accountphone = self.primary_phone + if accountphone is not None: + return accountphone + # No primary? Maybe there's one that's not set as primary? + if self.phones: + accountphone = self.phones[0] + # XXX: Mark as primary. This may or may not be saved depending on + # whether the request ended in a database commit. + self.primary_phone = accountphone + return accountphone + # This user has no phone number. Return a blank string instead of None + # to support the common use case, where the caller will use str(user.phone) + # to get the phone number as a string. + return '' + + with_roles(phone, read={'owner'}) + + @property + def has_public_profile(self) -> bool: + """Return the visibility state of an account.""" + return self.name is not None and bool(self.profile_state.ACTIVE_AND_PUBLIC) + + with_roles(has_public_profile, read={'all'}, write={'owner'}) + + @property + def profile_url(self) -> str | None: + """Return optional URL to account profile page.""" + return self.url_for(_external=True) + + with_roles(profile_url, read={'all'}) + + def is_profile_complete(self) -> bool: + """Verify if profile is complete (fullname, username and contacts present).""" + return bool(self.title and self.name and self.has_verified_contact_info) + + def active_memberships(self) -> Iterator[ImmutableMembershipMixin]: + """Enumerate all active memberships.""" + # Each collection is cast into a list before chaining to ensure that it does not + # change during processing (if, for example, membership is revoked or replaced). + return itertools.chain( + *(list(getattr(self, attr)) for attr in self.__active_membership_attrs__) + ) + + def has_any_memberships(self) -> bool: + """ + Test for any non-invite membership records that must be preserved. + + This is used to test for whether the account is safe to purge (hard delete) from + the database. If non-invite memberships are present, the account cannot be + purged as immutable records must be preserved. Instead, the account must be put + into DELETED state with all PII scrubbed. + """ + return any( + db.session.query(getattr(self, attr).exists()).scalar() + for attr in self.__noninvite_membership_attrs__ + ) + + # --- Transport details + + @with_roles(call={'owner'}) + def has_transport_email(self) -> bool: + """User has an email transport address.""" + return self.state.ACTIVE and bool(self.email) + + @with_roles(call={'owner'}) + def has_transport_sms(self) -> bool: + """User has an SMS transport address.""" + return ( + self.state.ACTIVE + and self.phone != '' + and self.phone.phone_number.has_sms is not False + ) + + @with_roles(call={'owner'}) + def has_transport_webpush(self) -> bool: # TODO # pragma: no cover + """User has a webpush transport address.""" + return False + + @with_roles(call={'owner'}) + def has_transport_telegram(self) -> bool: # TODO # pragma: no cover + """User has a Telegram transport address.""" + return False + + @with_roles(call={'owner'}) + def has_transport_whatsapp(self) -> bool: + """User has a WhatsApp transport address.""" + return ( + self.state.ACTIVE + and self.phone != '' + and self.phone.phone_number.has_wa is not False + ) + + @with_roles(call={'owner'}) + def transport_for_email(self, context: Model | None = None) -> AccountEmail | None: + """Return user's preferred email address within a context.""" + # TODO: Per-account/project customization is a future option + if self.state.ACTIVE: + return self.email or None + return None + + @with_roles(call={'owner'}) + def transport_for_sms(self, context: Model | None = None) -> AccountPhone | None: + """Return user's preferred phone number within a context.""" + # TODO: Per-account/project customization is a future option + if ( + self.state.ACTIVE + and self.phone != '' + and self.phone.phone_number.has_sms is not False + ): + return self.phone + return None + + @with_roles(call={'owner'}) + def transport_for_webpush( + self, context: Model | None = None + ): # TODO # pragma: no cover + """Return user's preferred webpush transport address within a context.""" + return None + + @with_roles(call={'owner'}) + def transport_for_telegram( + self, context: Model | None = None + ): # TODO # pragma: no cover + """Return user's preferred Telegram transport address within a context.""" + return None + + @with_roles(call={'owner'}) + def transport_for_whatsapp(self, context: Model | None = None): + """Return user's preferred WhatsApp transport address within a context.""" + # TODO: Per-account/project customization is a future option + if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.allow_wa: + return self.phone + return None + + @with_roles(call={'owner'}) + def transport_for_signal(self, context: Model | None = None): + """Return user's preferred Signal transport address within a context.""" + # TODO: Per-account/project customization is a future option + if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.allow_sm: + return self.phone + return None + + @with_roles(call={'owner'}) + def has_transport(self, transport: str) -> bool: + """ + Verify if user has a given transport address. + + Helper method to call ``self.has_transport_()``. + + ..note:: + Because this method does not accept a context, it may return True for a + transport that has been muted in that context. This may cause an empty + background job to be queued for a notification. Revisit this method when + preference contexts are supported. + """ + return getattr(self, 'has_transport_' + transport)() + + @with_roles(call={'owner'}) + def transport_for( + self, transport: str, context: Model | None = None + ) -> AccountEmail | AccountPhone | None: + """ + Get transport address for a given transport and context. + + Helper method to call ``self.transport_for_(context)``. + """ + return getattr(self, 'transport_for_' + transport)(context) + + def default_email( + self, context: Model | None = None + ) -> AccountEmail | AccountEmailClaim | None: + """ + Return default email address (verified if present, else unverified). + + ..note:: + This is a temporary helper method, pending merger of + :class:`AccountEmailClaim` into :class:`AccountEmail` with + :attr:`~AccountEmail.verified` ``== False``. The appropriate replacement is + :meth:`Account.transport_for_email` with a context. + """ + email = self.transport_for_email(context=context) + if email: + return email + # Fallback when ``transport_for_email`` returns None + if self.email: + return self.email + if self.emailclaims: + return self.emailclaims[0] + # This user has no email addresses + return None + + @property + def _self_is_owner_and_admin_of_self(self) -> Account: + """ + Return self. + + Helper method for :meth:`roles_for` and :meth:`actors_with` to assert that the + user is owner and admin of their own account. + """ + return self + + with_roles(_self_is_owner_and_admin_of_self, grants={'owner', 'admin'}) + + def organizations_as_owner_ids(self) -> list[int]: + """ + Return the database ids of the organizations this user is an owner of. + + This is used for database queries. + """ + return [ + membership.account_id + for membership in self.active_organization_owner_memberships + ] + + @state.transition(state.ACTIVE, state.MERGED) + def mark_merged_into(self, other_account): + """Mark account as merged into another account.""" + db.session.add(AccountOldId(id=self.uuid, account=other_account)) + + @state.transition(state.ACTIVE, state.SUSPENDED) + def mark_suspended(self): + """Mark account as suspended on support or moderator request.""" + + @state.transition(state.SUSPENDED, state.ACTIVE) + def mark_active(self): + """Restore a suspended account to active state.""" + + @state.transition(state.ACTIVE, state.DELETED) + def do_delete(self): + """Delete account.""" + # 0: Safety check + if not self.is_safe_to_delete(): + raise ValueError("Account cannot be deleted") + + # 1. Delete contact information + for contact_source in ( + self.emails, + self.emailclaims, + self.phones, + self.externalids, + ): + for contact in contact_source: + db.session.delete(contact) + + # 2. Revoke all active memberships + for membership in self.active_memberships(): + membership = membership.freeze_member_attribution(self) + if membership.revoke_on_member_delete: + membership.revoke(actor=self) + # TODO: freeze fullname in unrevoked memberships (pending title column there) + if ( + self.active_site_membership + and self.active_site_membership.revoke_on_member_delete + ): + self.active_site_membership.revoke(actor=self) + + # 3. Drop all team memberships + self.member_teams.clear() + + # 4. Revoke auth tokens + self.revoke_all_auth_tokens() # Defined in auth_client.py + self.revoke_all_auth_client_permissions() # Same place + + # 5. Revoke all active login sessions + for login_session in self.active_login_sessions: + login_session.revoke() + + # 6. Clear name (username), title (fullname) and stored password hash + self.name = None + self.title = '' + self.password = None + + # 7. Unassign tickets assigned to the user + self.ticket_participants = [] # pylint: disable=attribute-defined-outside-init + + @with_roles(call={'owner'}) + @profile_state.transition( + profile_state.NOT_PUBLIC, + profile_state.PUBLIC, + title=__("Make public"), + ) + @state.requires(state.ACTIVE) + def make_profile_public(self) -> None: + """Make an account public if it is eligible.""" + + @with_roles(call={'owner'}) + @profile_state.transition( + profile_state.NOT_PRIVATE, profile_state.PRIVATE, title=__("Make private") + ) + def make_profile_private(self) -> None: + """Make an account private.""" + + def is_safe_to_delete(self) -> bool: + """Test if account is not protected and has no projects.""" + return self.is_protected is False and self.projects.count() == 0 + + def is_safe_to_purge(self) -> bool: + """Test if account is safe to delete and has no memberships (active or not).""" + return self.is_safe_to_delete() and not self.has_any_memberships() + + @property + def urlname(self) -> str: + """Return :attr:`name` or ``~``-prefixed :attr:`uuid_zbase32`.""" + if self.name is not None: + return self.name + return f'~{self.uuid_zbase32}' + + @hybrid_property + def uuid_zbase32(self) -> str: + """Account UUID rendered in z-Base-32.""" + return zbase32_encode(self.uuid.bytes) + + @uuid_zbase32.inplace.comparator + @classmethod + def _uuid_zbase32_comparator(cls) -> ZBase32Comparator: + """Return SQL comparator for :prop:`uuid_zbase32`.""" + return ZBase32Comparator(cls.uuid) + + @classmethod + def name_is(cls, name: str) -> ColumnElement: + """Generate query filter to check if name is matching (case insensitive).""" + if name.startswith('~'): + return cls.uuid_zbase32 == name[1:] + return sa.func.lower(cls.name) == sa.func.lower(sa.func.replace(name, '-', '_')) + + @classmethod + def name_in(cls, names: Iterable[str]) -> ColumnElement: + """Generate query flter to check if name is among candidates.""" + return sa.func.lower(cls.name).in_( + [name.lower().replace('-', '_') for name in names] + ) + + @classmethod + def name_like(cls, like_query: str) -> ColumnElement: + """Generate query filter for a LIKE query on name.""" + return sa.func.lower(cls.name).like( + sa.func.lower(sa.func.replace(like_query, '-', r'\_')) + ) + + @overload + @classmethod + def get( + cls, + *, + name: str, + defercols: bool = False, + ) -> Account | None: + ... + + @overload + @classmethod + def get( + cls, + *, + buid: str, + defercols: bool = False, + ) -> Account | None: + ... + + @overload + @classmethod + def get( + cls, + *, + userid: str, + defercols: bool = False, + ) -> Account | None: + ... + + @classmethod + def get( + cls, + *, + name: str | None = None, + buid: str | None = None, + userid: str | None = None, + defercols: bool = False, + ) -> Account | None: + """ + Return an Account with the given name or buid. + + :param str name: Username to lookup + :param str buid: Buid to lookup + :param bool defercols: Defer loading non-critical columns + """ + require_one_of(name=name, buid=buid, userid=userid) + + # userid parameter is temporary for Flask-Lastuser compatibility + if userid: + buid = userid + + if name is not None: + query = cls.query.filter(cls.name_is(name)) + else: + query = cls.query.filter_by(buid=buid) + if cls is not Account: + query = query.filter(cls.type_filter()) + if defercols: + query = query.options(*cls._defercols()) + account = query.one_or_none() + if account and account.state.MERGED: + account = account.merged_account() + if account and account.state.ACTIVE: + return account + return None + + @classmethod + def all( # noqa: A003 + cls, + buids: Iterable[str] | None = None, + names: Iterable[str] | None = None, + defercols: bool = False, + ) -> list[Account]: + """ + Return all matching accounts. + + :param list buids: Buids to look up + :param list names: Names (usernames) to look up + :param bool defercols: Defer loading non-critical columns + """ + accounts = set() + if buids and names: + query = cls.query.filter(sa.or_(cls.buid.in_(buids), cls.name_in(names))) + elif buids: + query = cls.query.filter(cls.buid.in_(buids)) + elif names: + query = cls.query.filter(cls.name_in(names)) + else: + return [] + if cls is not Account: + query = query.filter(cls.type_filter()) + + if defercols: + query = query.options(*cls._defercols()) + for account in query.all(): + account = account.merged_account() + if account.state.ACTIVE: + accounts.add(account) + return list(accounts) + + @classmethod + def all_public(cls) -> Query: + """Construct a query filtered by public profile state.""" + query = cls.query.filter(cls.profile_state.PUBLIC) + if cls is not Account: + query = query.filter(cls.type_filter()) + return query + + @classmethod + def autocomplete(cls, prefix: str) -> list[Account]: + """ + Return accounts whose names begin with the prefix, for autocomplete UI. + + Looks up accounts by title, name, external ids and email addresses. + + :param prefix: Letters to start matching with + """ + like_query = quote_autocomplete_like(prefix) + if not like_query or like_query == '@%': + return [] + tsquery = quote_autocomplete_tsquery(prefix) + + # base_users is used in two of the three possible queries below + base_users = cls.query.filter( + cls.state.ACTIVE, + cls.name_vector.bool_op('@@')(tsquery), + ) + + if cls is not Account: + base_users = base_users.filter(cls.type_filter()) + base_users = ( + base_users.options(*cls._defercols()).order_by(Account.title).limit(20) + ) + + if ( + prefix != '@' + and prefix.startswith('@') + and AccountExternalId.__at_username_services__ + ): + # @-prefixed, so look for usernames, including other @username-using + # services like Twitter and GitHub. Make a union of three queries. + users = ( + # Query 1: @query -> Account.name + cls.query.filter( + cls.state.ACTIVE, + cls.name_like(like_query[1:]), + ) + .options(*cls._defercols()) + .limit(20) + # FIXME: Still broken as of SQLAlchemy 1.4.23 (also see next block) + # .union( + # # Query 2: @query -> UserExternalId.username + # cls.query.join(UserExternalId) + # .filter( + # cls.state.ACTIVE, + # UserExternalId.service.in_( + # UserExternalId.__at_username_services__ + # ), + # sa.func.lower(UserExternalId.username).like( + # sa.func.lower(like_query[1:]) + # ), + # ) + # .options(*cls._defercols()) + # .limit(20), + # # Query 3: like_query -> Account.title + # cls.query.filter( + # cls.state.ACTIVE, + # sa.func.lower(cls.title).like(sa.func.lower(like_query)), + # ) + # .options(*cls._defercols()) + # .limit(20), + # ) + .all() + ) + elif '@' in prefix and not prefix.startswith('@'): + # Query has an @ in the middle. Match email address (exact match only). + # Use param `prefix` instead of `like_query` because it's not a LIKE query. + # Combine results with regular user search + email_filter = EmailAddress.get_filter(email=prefix) + if email_filter is not None: + users = ( + cls.query.join(AccountEmail) + .join(EmailAddress) + .filter(email_filter, cls.state.ACTIVE) + .options(*cls._defercols()) + .limit(20) + # .union(base_users) # FIXME: Broken in SQLAlchemy 1.4.17 + .all() + ) + else: + users = [] + else: + # No '@' in the query, so do a regular autocomplete + try: + users = base_users.all() + except sa.exc.ProgrammingError: + # This can happen because the tsquery from prefix turned out to be ':*' + users = [] + return users + + @classmethod + def validate_name_candidate(cls, name: str) -> str | None: + """ + Validate an account name candidate. + + Returns one of several error codes, or `None` if all is okay: + + * ``blank``: No name supplied + * ``reserved``: Name is reserved + * ``invalid``: Invalid characters in name + * ``long``: Name is longer than allowed size + * ``user``: Name is assigned to a user + * ``org``: Name is assigned to an organization + """ + if not name: + return 'blank' + if name.lower() in cls.reserved_names: + return 'reserved' + if not valid_account_name(name): + return 'invalid' + if len(name) > cls.__name_length__: + return 'long' + # Look for existing on the base Account model, not the subclass, as SQLAlchemy + # will add a filter condition on subclasses to restrict the query to that type. + existing = ( + Account.query.filter(sa.func.lower(Account.name) == sa.func.lower(name)) + .options(sa.orm.load_only(cls.id, cls.uuid, cls.type_)) + .one_or_none() + ) + if existing is not None: + if isinstance(existing, Placeholder): + return 'reserved' + if isinstance(existing, User): + return 'user' + if isinstance(existing, Organization): + return 'org' + return None + + def validate_new_name(self, name: str) -> str | None: + """Validate a new name for this account, returning an error code or None.""" + if self.name and name.lower() == self.name.lower(): + return None + return self.validate_name_candidate(name) + + @classmethod + def is_available_name(cls, name: str) -> bool: + """Test if the candidate name is available for use as an Account name.""" + return cls.validate_name_candidate(name) is None + + @sa.orm.validates('name') + def _validate_name(self, key: str, value: str | None) -> str | None: + """Validate the value of Account.name.""" + if value is None: + return value + + if not isinstance(value, str): + raise ValueError(f"Account name must be a string: {value}") + + if not value.strip(): + raise ValueError("Account name cannot be blank") + + if value.lower() in self.reserved_names or not valid_account_name(value): + raise ValueError("Invalid account name: " + value) + + # We don't check for existence in the db since this validator only + # checks for valid syntax. To confirm the name is actually available, + # the caller must call :meth:`is_available_name` or attempt to commit + # to the db and catch IntegrityError. + return value + + @sa.orm.validates('logo_url', 'banner_image_url') + def _validate_nullable(self, key: str, value: str | None): + """Convert blank values into None.""" + return value if value else None + + @classmethod + def active_count(cls) -> int: + """Count of all active accounts.""" + return cls.query.filter(cls.state.ACTIVE).count() + + #: FIXME: Temporary values for Baseframe compatibility + def organization_links(self) -> list: + """Return list of organizations affiliated with this user (deprecated).""" + return [] + + # Make :attr:`type_` available under the name `type`, but declare this at the very + # end of the class to avoid conflicts with the Python `type` global that is + # used for type-hinting + type: Mapped[str] = sa.orm.synonym('type_') # noqa: A003 + + +auto_init_default(Account._state) # pylint: disable=protected-access +auto_init_default(Account._profile_state) # pylint: disable=protected-access +add_search_trigger(Account, 'search_vector') +add_search_trigger(Account, 'name_vector') + + +class AccountOldId(UuidMixin, BaseMixin, Model): + """Record of an older UUID for an account, after account merger.""" + + __tablename__ = 'account_oldid' + __uuid_primary_key__ = True + + #: Old account, if still present + old_account: Mapped[Account] = relationship( + Account, + primaryjoin='foreign(AccountOldId.id) == remote(Account.uuid)', + backref=backref('oldid', uselist=False), + ) + #: User id of new user + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + #: New account + account: Mapped[Account] = relationship( + Account, + foreign_keys=[account_id], + backref=backref('oldids', cascade='all'), + ) + + def __repr__(self) -> str: + """Represent :class:`AccountOldId` as a string.""" + return f'' + + @classmethod + def get(cls, uuid: UUID) -> AccountOldId | None: + """Get an old user record given a UUID.""" + return cls.query.filter_by(id=uuid).one_or_none() + + +class User(Account): + """User account.""" + + __mapper_args__ = {'polymorphic_identity': 'U'} + is_user_profile = True + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + if self.joined_at is None: + self.joined_at = sa.func.utcnow() + + +# XXX: Deprecated, still here for Baseframe compatibility +Account.userid = Account.uuid_b64 + + +class DuckTypeAccount(RoleMixin): + """User singleton constructor. Ducktypes a regular user object.""" + + id: None = None # noqa: A003 + created_at: None = None + updated_at: None = None + uuid: None = None + userid: None = None + buid: None = None + uuid_b58: None = None + username: None = None + name: None = None + profile_url: None = None + email: None = None + phone: None = None + + is_user_profile = True + is_organization_profile = False + is_placeholder_profile = False + + # Copy registries from Account model + views = Account.views + features = Account.features + forms = Account.forms + + __roles__ = { + 'all': { + 'read': { + 'id', + 'uuid', + 'username', + 'fullname', + 'pickername', + 'profile_url', + }, + 'call': {'views', 'forms', 'features', 'url_for'}, + } + } + + __datasets__ = { + 'related': { + 'username', + 'fullname', + 'pickername', + 'profile_url', + } + } + + #: Make obj.user/obj.posted_by from a referring object falsy + def __bool__(self) -> bool: + """Represent boolean state.""" + return False + + def __init__(self, representation: str) -> None: + self.fullname = self.title = self.pickername = representation + + def __str__(self) -> str: + return self.pickername + + def __format__(self, format_spec: str) -> str: + if not format_spec: + return self.pickername + return self.pickername.__format__(format_spec) + + def url_for(self, *args, **kwargs) -> Literal['']: + """Return blank URL for anything to do with this user.""" + return '' + + +deleted_account = DuckTypeAccount(__("[deleted]")) +removed_account = DuckTypeAccount(__("[removed]")) +unknown_account = DuckTypeAccount(__("[unknown]")) + + +# --- Organizations and teams ------------------------------------------------- + +team_membership = sa.Table( + 'team_membership', + Model.metadata, + sa.Column( + 'account_id', + sa.Integer, + sa.ForeignKey('account.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'team_id', + sa.Integer, + sa.ForeignKey('team.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'created_at', + sa.TIMESTAMP(timezone=True), + nullable=False, + default=sa.func.utcnow(), + ), +) + + +class Organization(Account): + """An organization of one or more users with distinct roles.""" + + __mapper_args__ = {'polymorphic_identity': 'O'} + is_organization_profile = True + + def __init__(self, owner: User, **kwargs) -> None: + super().__init__(**kwargs) + if self.joined_at is None: + self.joined_at = sa.func.utcnow() + db.session.add( + AccountMembership( + account=self, member=owner, granted_by=owner, is_owner=True + ) + ) + + def people(self) -> Query[Account]: + """Return a list of users from across the public teams they are in.""" + return ( + Account.query.join(team_membership) + .join(Team) + .filter(Team.account == self, Team.is_public.is_(True)) + .options(sa.orm.joinedload(Account.member_teams)) + .order_by(sa.func.lower(Account.title)) + ) + + +class Placeholder(Account): + """A placeholder account.""" + + __mapper_args__ = {'polymorphic_identity': 'P'} + is_placeholder_profile = True + + +class Team(UuidMixin, BaseMixin, Model): + """A team of users within an organization.""" + + __tablename__ = 'team' + __title_length__ = 250 + #: Displayed name + title: Mapped[str] = sa.orm.mapped_column( + sa.Unicode(__title_length__), nullable=False + ) + #: Organization + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False, index=True + ) + account = with_roles( + relationship( + Account, + foreign_keys=[account_id], + backref=backref('teams', order_by=sa.func.lower(title), cascade='all'), + ), + grants_via={None: {'owner': 'owner', 'admin': 'admin'}}, + ) + users: DynamicMapped[Account] = with_roles( + relationship( + Account, secondary=team_membership, lazy='dynamic', backref='member_teams' + ), + grants={'member'}, + ) + + is_public: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + + def __repr__(self) -> str: + """Represent :class:`Team` as a string.""" + return f'' + + @property + def pickername(self) -> str: + """Return team's title in a format suitable for identification.""" + return self.title + + @classmethod + def migrate_account( + cls, old_account: Account, new_account: Account + ) -> OptionalMigratedTables: + """Migrate one account's data to another when merging accounts.""" + for team in list(old_account.teams): + team.account = new_account + for team in list(old_account.member_teams): + if team not in new_account.member_teams: + # FIXME: This creates new memberships, updating `created_at`. + # Unfortunately, we can't work with model instances as in the other + # `migrate_account` methods as team_membership is an unmapped table. + new_account.member_teams.append(team) + old_account.member_teams.remove(team) + return [cls.__table__.name, team_membership.name] + + @classmethod + def get(cls, buid: str, with_parent: bool = False) -> Team | None: + """ + Return a Team with matching buid. + + :param str buid: Buid of the team + """ + if with_parent: + query = cls.query.options(sa.orm.joinedload(cls.account)) + else: + query = cls.query + return query.filter_by(buid=buid).one_or_none() + + +# --- Account email/phone and misc + + +class AccountEmail(EmailAddressMixin, BaseMixin, Model): + """An email address linked to an account.""" + + __tablename__ = 'account_email' + __email_optional__ = False + __email_unique__ = True + __email_is_exclusive__ = True + __email_for__ = 'account' + + # Tell mypy that these are not optional + email_address: Mapped[EmailAddress] # type: ignore[assignment] + + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship( + Account, backref=backref('emails', cascade='all') + ) + user: Mapped[Account] = sa.orm.synonym('account') + + private: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + + __datasets__ = { + 'primary': {'member', 'email', 'private', 'type'}, + 'without_parent': {'email', 'private', 'type'}, + 'related': {'email', 'private', 'type'}, + } + + def __init__(self, account: Account, **kwargs) -> None: + email = kwargs.pop('email', None) + if email: + kwargs['email_address'] = EmailAddress.add_for(account, email) + super().__init__(account=account, **kwargs) + + def __repr__(self) -> str: + """Represent this class as a string.""" + return f'' + + def __str__(self) -> str: # pylint: disable=invalid-str-returned + """Email address as a string.""" + return self.email or '' + + @property + def primary(self) -> bool: + """Check whether this email address is the user's primary.""" + return self.account.primary_email == self + + @primary.setter + def primary(self, value: bool) -> None: + """Set or unset this email address as primary.""" + if value: + self.account.primary_email = self + else: + if self.account.primary_email == self: + self.account.primary_email = None + + @overload + @classmethod + def get( + cls, + email: str, + ) -> AccountEmail | None: + ... + + @overload + @classmethod + def get( + cls, + *, + blake2b160: bytes, + ) -> AccountEmail | None: + ... + + @overload + @classmethod + def get( + cls, + *, + email_hash: str, + ) -> AccountEmail | None: + ... + + @classmethod + def get( + cls, + email: str | None = None, + *, + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> AccountEmail | None: + """ + Return an AccountEmail with matching email or blake2b160 hash. + + :param email: Email address to look up + :param blake2b160: 160-bit blake2b of email address to look up + :param email_hash: blake2b hash rendered in Base58 + """ + email_filter = EmailAddress.get_filter( + email=email, blake2b160=blake2b160, email_hash=email_hash + ) + if email_filter is None: + return None + return cls.query.join(EmailAddress).filter(email_filter).one_or_none() + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + email: str, + ) -> AccountEmail | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + blake2b160: bytes, + ) -> AccountEmail | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + email_hash: str, + ) -> AccountEmail | None: + ... + + @classmethod + def get_for( + cls, + account: Account, + *, + email: str | None = None, + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> AccountEmail | None: + """ + Return instance with matching email or hash if it belongs to the given user. + + :param user: Account to look up for + :param email: Email address to look up + :param blake2b160: 160-bit blake2b of email address + :param email_hash: blake2b hash rendered in Base58 + """ + email_filter = EmailAddress.get_filter( + email=email, blake2b160=blake2b160, email_hash=email_hash + ) + if email_filter is None: + return None + return ( + cls.query.join(EmailAddress) + .filter( + cls.account == account, + email_filter, + ) + .one_or_none() + ) + + @classmethod + def migrate_account( + cls, old_account: Account, new_account: Account + ) -> OptionalMigratedTables: + """Migrate one account's data to another when merging accounts.""" + primary_email = old_account.primary_email + for accountemail in list(old_account.emails): + accountemail.account = new_account + if new_account.primary_email is None: + new_account.primary_email = primary_email + old_account.primary_email = None + return [cls.__table__.name, user_email_primary_table.name] + + +class AccountEmailClaim(EmailAddressMixin, BaseMixin, Model): + """Claimed but unverified email address for a user.""" + + __tablename__ = 'account_email_claim' + __email_optional__ = False + __email_unique__ = False + __email_for__ = 'account' + __email_is_exclusive__ = False + + # Tell mypy that these are not optional + email_address: Mapped[EmailAddress] # type: ignore[assignment] + + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship( + Account, backref=backref('emailclaims', cascade='all') + ) + user: Mapped[Account] = sa.orm.synonym('account') + verification_code: Mapped[str] = sa.orm.mapped_column( + sa.String(44), nullable=False, default=newsecret + ) + + private: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + + __table_args__ = (sa.UniqueConstraint('account_id', 'email_address_id'),) + + __datasets__ = { + 'primary': {'member', 'email', 'private', 'type'}, + 'without_parent': {'email', 'private', 'type'}, + 'related': {'email', 'private', 'type'}, + } + + def __init__(self, account: Account, **kwargs) -> None: + email = kwargs.pop('email', None) + if email: + kwargs['email_address'] = EmailAddress.add_for(account, email) + super().__init__(account=account, **kwargs) + self.blake2b = hashlib.blake2b( + self.email.lower().encode(), digest_size=16 + ).digest() + + def __repr__(self) -> str: + """Represent this class as a string.""" + return f'' + + def __str__(self) -> str: + """Return email as a string.""" + return str(self.email) + + @classmethod + def migrate_account(cls, old_account: Account, new_account: Account) -> None: + """Migrate one account's data to another when merging accounts.""" + emails = {claim.email for claim in new_account.emailclaims} + for claim in list(old_account.emailclaims): + if claim.email not in emails: + claim.account = new_account + else: + # New user also made the same claim. Delete old user's claim + db.session.delete(claim) + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + email: str, + ) -> AccountEmailClaim | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + blake2b160: bytes, + ) -> AccountEmailClaim | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + email_hash: str, + ) -> AccountEmailClaim | None: + ... + + @classmethod + def get_for( + cls, + account: Account, + *, + email: str | None = None, + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> AccountEmailClaim | None: + """ + Return an AccountEmailClaim with matching email address for the given user. + + :param account: Account that claimed this email address + :param email: Email address to look up + :param blake2b160: 160-bit blake2b of email address to look up + :param email_hash: Base58 rendering of 160-bit blake2b hash + """ + email_filter = EmailAddress.get_filter( + email=email, blake2b160=blake2b160, email_hash=email_hash + ) + if email_filter is None: + return None + return ( + cls.query.join(EmailAddress) + .filter( + cls.account == account, + email_filter, + ) + .one_or_none() + ) + + @overload + @classmethod + def get_by( + cls, + verification_code: str, + *, + email: str, + ) -> AccountEmailClaim | None: + ... + + @overload + @classmethod + def get_by( + cls, + verification_code: str, + *, + blake2b160: bytes, + ) -> AccountEmailClaim | None: + ... + + @overload + @classmethod + def get_by( + cls, + verification_code: str, + *, + email_hash: str, + ) -> AccountEmailClaim | None: + ... + + @classmethod + def get_by( + cls, + verification_code: str, + *, + email: str | None = None, + blake2b160: bytes | None = None, + email_hash: str | None = None, + ) -> AccountEmailClaim | None: + """Return an instance given verification code and email or hash.""" + email_filter = EmailAddress.get_filter( + email=email, blake2b160=blake2b160, email_hash=email_hash + ) + if email_filter is None: + return None + return ( + cls.query.join(EmailAddress) + .filter( + cls.verification_code == verification_code, + email_filter, + ) + .one_or_none() + ) + + @classmethod + def all(cls, email: str) -> Query[AccountEmailClaim]: # noqa: A003 + """ + Return all instances with the matching email address. + + :param str email: Email address to lookup + """ + email_filter = EmailAddress.get_filter(email=email) + if email_filter is None: + raise ValueError(email) + return cls.query.join(EmailAddress).filter(email_filter) + + +auto_init_default(AccountEmailClaim.verification_code) + + +class AccountPhone(PhoneNumberMixin, BaseMixin, Model): + """A phone number linked to an account.""" + + __tablename__ = 'account_phone' + __phone_optional__ = False + __phone_unique__ = True + __phone_is_exclusive__ = True + __phone_for__ = 'account' + + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship( + Account, backref=backref('phones', cascade='all') + ) + user: Mapped[Account] = sa.orm.synonym('account') + + private: Mapped[bool] = sa.orm.mapped_column( + sa.Boolean, nullable=False, default=False + ) + + __datasets__ = { + 'primary': {'member', 'phone', 'private', 'type'}, + 'without_parent': {'phone', 'private', 'type'}, + 'related': {'phone', 'private', 'type'}, + } + + def __init__(self, account, **kwargs) -> None: + phone = kwargs.pop('phone', None) + if phone: + kwargs['phone_number'] = PhoneNumber.add_for(account, phone) + super().__init__(account=account, **kwargs) + + def __repr__(self) -> str: + """Represent this class as a string.""" + return f'AccountPhone(phone={self.phone!r}, account={self.account!r})' + + def __str__(self) -> str: + """Return phone number as a string.""" + return self.phone or '' + + @cached_property + def parsed(self) -> phonenumbers.PhoneNumber: + """Return parsed phone number using libphonenumbers.""" + return self.phone_number.parsed + + @cached_property + def formatted(self) -> str: + """Return a phone number formatted for user display.""" + return self.phone_number.formatted + + @property + def number(self) -> str | None: + return self.phone_number.number + + @property + def primary(self) -> bool: + """Check if this is the user's primary phone number.""" + return self.account.primary_phone == self + + @primary.setter + def primary(self, value: bool) -> None: + if value: + self.account.primary_phone = self + else: + if self.account.primary_phone == self: + self.account.primary_phone = None + + @overload + @classmethod + def get( + cls, + phone: str, + ) -> AccountPhone | None: + ... + + @overload + @classmethod + def get( + cls, + *, + blake2b160: bytes, + ) -> AccountPhone | None: + ... + + @overload + @classmethod + def get( + cls, + *, + phone_hash: str, + ) -> AccountPhone | None: + ... + + @classmethod + def get( + cls, + phone: str | None = None, + *, + blake2b160: bytes | None = None, + phone_hash: str | None = None, + ) -> AccountPhone | None: + """ + Return an AccountPhone with matching phone number. + + :param phone: Phone number to lookup + :param blake2b160: 160-bit blake2b of phone number to look up + :param phone_hash: blake2b hash rendered in Base58 + """ + return ( + cls.query.join(PhoneNumber) + .filter( + PhoneNumber.get_filter( + phone=phone, blake2b160=blake2b160, phone_hash=phone_hash + ) + ) + .one_or_none() + ) + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + phone: str, + ) -> AccountPhone | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + blake2b160: bytes, + ) -> AccountPhone | None: + ... + + @overload + @classmethod + def get_for( + cls, + account: Account, + *, + phone_hash: str, + ) -> AccountPhone | None: + ... + + @classmethod + def get_for( + cls, + account: Account, + *, + phone: str | None = None, + blake2b160: bytes | None = None, + phone_hash: str | None = None, + ) -> AccountPhone | None: + """ + Return an instance with matching phone or hash if it belongs to the given user. + + :param account: Account to look up for + :param phone: Email address to look up + :param blake2b160: 160-bit blake2b of phone number + :param phone_hash: blake2b hash rendered in Base58 + """ + return ( + cls.query.join(PhoneNumber) + .filter( + cls.account == account, + PhoneNumber.get_filter( + phone=phone, blake2b160=blake2b160, phone_hash=phone_hash + ), + ) + .one_or_none() + ) + + @classmethod + def migrate_account( + cls, old_account: Account, new_account: Account + ) -> OptionalMigratedTables: + """Migrate one account's data to another when merging accounts.""" + primary_phone = old_account.primary_phone + for accountphone in list(old_account.phones): + accountphone.account = new_account + if new_account.primary_phone is None: + new_account.primary_phone = primary_phone + old_account.primary_phone = None + return [cls.__table__.name, user_phone_primary_table.name] + + +class AccountExternalId(BaseMixin, Model): + """An external connected account for a user.""" + + __tablename__ = 'account_externalid' + __at_username_services__: ClassVar[list[str]] = [] + #: Foreign key to user table + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + #: User that this connected account belongs to + account: Mapped[Account] = relationship( + Account, backref=backref('externalids', cascade='all') + ) + user: Mapped[Account] = sa.orm.synonym('account') + #: Identity of the external service (in app's login provider registry) + # FIXME: change to sa.Unicode + service: Mapped[str] = sa.orm.mapped_column(sa.UnicodeText, nullable=False) + #: Unique user id as per external service, used for identifying related accounts + # FIXME: change to sa.Unicode + userid: Mapped[str] = sa.orm.mapped_column( + sa.UnicodeText, nullable=False + ) # Unique id (or obsolete OpenID) + #: Optional public-facing username on the external service + # FIXME: change to sa.Unicode + username: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) # LinkedIn once used full URLs + #: OAuth or OAuth2 access token + # FIXME: change to sa.Unicode + oauth_token: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) + #: Optional token secret (not used in OAuth2, used by Twitter with OAuth1a) + # FIXME: change to sa.Unicode + oauth_token_secret: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) + #: OAuth token type (typically 'bearer') + # FIXME: change to sa.Unicode + oauth_token_type: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) + #: OAuth2 refresh token + # FIXME: change to sa.Unicode + oauth_refresh_token: Mapped[str | None] = sa.orm.mapped_column( + sa.UnicodeText, nullable=True + ) + #: OAuth2 token expiry in seconds, as sent by service provider + oauth_expires_in: Mapped[int | None] = sa.orm.mapped_column( + sa.Integer, nullable=True + ) + #: OAuth2 token expiry timestamp, estimate from created_at + oauth_expires_in + oauth_expires_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True, index=True + ) + + #: Timestamp of when this connected account was last (re-)authorised by the user + last_used_at: Mapped[datetime] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), default=sa.func.utcnow(), nullable=False + ) + + __table_args__ = ( + sa.UniqueConstraint('service', 'userid'), + sa.Index( + 'ix_account_externalid_username_lower', + sa.func.lower(username).label('username_lower'), + postgresql_ops={'username_lower': 'varchar_pattern_ops'}, + ), + ) + + def __repr__(self) -> str: + """Represent :class:`UserExternalId` as a string.""" + return f'' + + @overload + @classmethod + def get( + cls, + service: str, + *, + userid: str, + ) -> AccountExternalId | None: + ... + + @overload + @classmethod + def get( + cls, + service: str, + *, + username: str, + ) -> AccountExternalId | None: + ... + + @classmethod + def get( + cls, + service: str, + *, + userid: str | None = None, + username: str | None = None, + ) -> AccountExternalId | None: + """ + Return a UserExternalId with the given service and userid or username. + + :param str service: Service to lookup + :param str userid: Userid to lookup + :param str username: Username to lookup (may be non-unique) + + Usernames are not guaranteed to be unique within a service. An example is with + Google, where the userid is a directed OpenID URL, unique but subject to change + if the Lastuser site URL changes. The username is the email address, which will + be the same despite different userids. + """ + param, value = require_one_of(True, userid=userid, username=username) + return cls.query.filter_by(**{param: value, 'service': service}).one_or_none() + + +user_email_primary_table = add_primary_relationship( + Account, 'primary_email', AccountEmail, 'account', 'account_id' +) +user_phone_primary_table = add_primary_relationship( + Account, 'primary_phone', AccountPhone, 'account', 'account_id' +) + +#: Anchor type +Anchor = Union[AccountEmail, AccountEmailClaim, AccountPhone, EmailAddress, PhoneNumber] + +# Tail imports +# pylint: disable=wrong-import-position +from .membership_mixin import ImmutableMembershipMixin # isort: skip +from .account_membership import AccountMembership # isort:skip diff --git a/funnel/models/account_membership.py b/funnel/models/account_membership.py new file mode 100644 index 000000000..80c4b97b7 --- /dev/null +++ b/funnel/models/account_membership.py @@ -0,0 +1,234 @@ +"""Membership model for admins of an organization.""" + +from __future__ import annotations + +from werkzeug.utils import cached_property + +from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles + +from . import DynamicMapped, Mapped, Model, backref, relationship, sa +from .account import Account +from .helpers import reopen +from .membership_mixin import ImmutableUserMembershipMixin + +__all__ = ['AccountMembership'] + + +class AccountMembership(ImmutableUserMembershipMixin, Model): + """ + An account can be a member of another account as an owner, admin or follower. + + Owners can manage other administrators. + + TODO: This model may introduce non-admin memberships in a future iteration by + replacing :attr:`is_owner` with :attr:`member_level` or distinct role flags as in + :class:`ProjectMembership`. + """ + + __tablename__ = 'account_membership' + + # Legacy data has no granted_by + __null_granted_by__ = True + + #: List of role columns in this model + __data_columns__ = ('is_owner',) + + __roles__ = { + 'all': { + 'read': { + 'urls', + 'member', + 'is_owner', + 'account', + 'granted_by', + 'revoked_by', + 'granted_at', + 'revoked_at', + 'is_self_granted', + 'is_self_revoked', + } + }, + 'account_admin': { + 'read': { + 'record_type', + 'record_type_label', + 'granted_at', + 'granted_by', + 'revoked_at', + 'revoked_by', + 'member', + 'is_active', + 'is_invite', + 'is_self_granted', + 'is_self_revoked', + } + }, + } + __datasets__ = { + 'primary': { + 'urls', + 'uuid_b58', + 'offered_roles', + 'is_owner', + 'member', + 'account', + }, + 'without_parent': {'urls', 'uuid_b58', 'offered_roles', 'is_owner', 'member'}, + 'related': {'urls', 'uuid_b58', 'offered_roles', 'is_owner'}, + } + + #: Organization that this membership is being granted on + account_id: Mapped[int] = sa.orm.mapped_column( + sa.Integer, + sa.ForeignKey('account.id', ondelete='CASCADE'), + nullable=False, + ) + account: Mapped[Account] = with_roles( + relationship( + Account, + foreign_keys=[account_id], + backref=backref( + 'memberships', lazy='dynamic', cascade='all', passive_deletes=True + ), + ), + grants_via={None: {'admin': 'account_admin', 'owner': 'account_owner'}}, + ) + parent_id: Mapped[int] = sa.orm.synonym('account_id') + parent_id_column = 'account_id' + parent: Mapped[Account] = sa.orm.synonym('account') + + # Organization roles: + is_owner: Mapped[bool] = immutable( + sa.orm.mapped_column(sa.Boolean, nullable=False, default=False) + ) + + @cached_property + def offered_roles(self) -> set[str]: + """Roles offered by this membership record.""" + roles = {'admin'} + if self.is_owner: + roles.add('owner') + return roles + + +# Add active membership relationships to Account +@reopen(Account) +class __Account: + active_admin_memberships: DynamicMapped[AccountMembership] = with_roles( + relationship( + AccountMembership, + lazy='dynamic', + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.account_id) == Account.id, + AccountMembership.is_active, + ), + order_by=AccountMembership.granted_at.asc(), + viewonly=True, + ), + grants_via={'member': {'admin', 'owner'}}, + ) + + active_owner_memberships: DynamicMapped[AccountMembership] = relationship( + AccountMembership, + lazy='dynamic', + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.account_id) == Account.id, + AccountMembership.is_active, + AccountMembership.is_owner.is_(True), + ), + viewonly=True, + ) + + active_invitations: DynamicMapped[AccountMembership] = relationship( + AccountMembership, + lazy='dynamic', + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.account_id) == Account.id, + AccountMembership.is_invite, + AccountMembership.revoked_at.is_(None), + ), + viewonly=True, + ) + + owner_users = with_roles( + DynamicAssociationProxy('active_owner_memberships', 'member'), read={'all'} + ) + admin_users = with_roles( + DynamicAssociationProxy('active_admin_memberships', 'member'), read={'all'} + ) + + # pylint: disable=invalid-unary-operand-type + organization_admin_memberships: DynamicMapped[AccountMembership] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], # type: ignore[has-type] + viewonly=True, + ) + + noninvite_organization_admin_memberships: DynamicMapped[ + AccountMembership + ] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] + == Account.id, + ~AccountMembership.is_invite, + ), + viewonly=True, + ) + + active_organization_admin_memberships: DynamicMapped[ + AccountMembership + ] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] + == Account.id, + AccountMembership.is_active, + ), + viewonly=True, + ) + + active_organization_owner_memberships: DynamicMapped[ + AccountMembership + ] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] + == Account.id, + AccountMembership.is_active, + AccountMembership.is_owner.is_(True), + ), + viewonly=True, + ) + + active_organization_invitations: DynamicMapped[AccountMembership] = relationship( + AccountMembership, + lazy='dynamic', + foreign_keys=[AccountMembership.member_id], + primaryjoin=sa.and_( + sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] + == Account.id, + AccountMembership.is_invite, + AccountMembership.revoked_at.is_(None), + ), + viewonly=True, + ) + + organizations_as_owner = DynamicAssociationProxy( + 'active_organization_owner_memberships', 'account' + ) + + organizations_as_admin = DynamicAssociationProxy( + 'active_organization_admin_memberships', 'account' + ) + + +Account.__active_membership_attrs__.add('active_organization_admin_memberships') +Account.__noninvite_membership_attrs__.add('noninvite_organization_admin_memberships') diff --git a/funnel/models/auth_client.py b/funnel/models/auth_client.py index e8a627b04..cf8281010 100644 --- a/funnel/models/auth_client.py +++ b/funnel/models/auth_client.py @@ -3,19 +3,10 @@ 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, -) +from typing import cast, overload from sqlalchemy.orm import attribute_keyed_dict, load_only from sqlalchemy.orm.query import Query as QueryBaseClass @@ -25,7 +16,6 @@ from coaster.sqlalchemy import with_roles from coaster.utils import buid as make_buid, newsecret, require_one_of, utcnow -from ..typing import OptionalMigratedTables from . import ( BaseMixin, DynamicMapped, @@ -33,15 +23,16 @@ 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', @@ -49,7 +40,7 @@ 'AuthClient', 'AuthClientCredential', 'AuthClientTeamPermissions', - 'AuthClientUserPermissions', + 'AuthClientPermissions', ] @@ -68,14 +59,14 @@ def _scope(cls) -> Mapped[str]: ) @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 @@ -87,7 +78,7 @@ 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] @@ -98,36 +89,20 @@ 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[Optional[int]] = sa.orm.mapped_column( - sa.Integer, sa.ForeignKey('user.id'), nullable=True - ) - user: Mapped[Optional[User]] = with_roles( - 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[Optional[int]] = sa.orm.mapped_column( - sa.Integer, sa.ForeignKey('organization.id'), nullable=True + #: Account that owns this client + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=True ) - organization: Mapped[Optional[User]] = with_roles( + account: Mapped[Account | None] = with_roles( relationship( - Organization, - foreign_keys=[organization_id], - backref=sa.orm.backref('clients', cascade='all'), + 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( @@ -152,7 +127,7 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): write={'owner'}, ) #: Redirect URIs (one or more) - _redirect_uris: Mapped[Optional[str]] = sa.orm.mapped_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) @@ -179,20 +154,11 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): sa.orm.mapped_column(sa.Boolean, nullable=False, default=False), read={'all'} ) - user_sessions: DynamicMapped[UserSession] = 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.is_not(None), 1), else_=0) - + sa.case((organization_id.is_not(None), 1), else_=0) - == 1, - name='auth_client_owner_check', - ), + secondary=auth_client_login_session, + backref=backref('auth_clients', lazy='dynamic'), ) __roles__ = { @@ -212,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 () @@ -224,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: @@ -241,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. @@ -293,14 +252,14 @@ 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]) -> Query[AuthClient]: - """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) @@ -312,8 +271,8 @@ class AuthClientCredential(BaseMixin, Model): 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 @@ -325,14 +284,13 @@ class AuthClientCredential(BaseMixin, Model): """ __tablename__ = 'auth_client_credential' - __allow_unmapped__ = True auth_client_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False ) auth_client: Mapped[AuthClient] = with_roles( relationship( AuthClient, - backref=sa.orm.backref( + backref=backref( 'credentials', cascade='all, delete-orphan', collection_class=attribute_keyed_dict('name'), @@ -352,14 +310,14 @@ class AuthClientCredential(BaseMixin, Model): #: OAuth client secret, hashed 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[Optional[datetime]] = sa.orm.mapped_column( + accessed_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) def __repr__(self) -> str: return f'' - def secret_is(self, candidate: Optional[str], upgrade_hash: bool = False) -> bool: + def secret_is(self, candidate: str | None, upgrade_hash: bool = False) -> bool: """Test if the candidate secret matches.""" if not candidate: return False @@ -383,12 +341,12 @@ def secret_is(self, candidate: Optional[str], upgrade_hash: bool = False) -> boo return False @classmethod - def get(cls, name: str) -> Optional[AuthClientCredential]: + 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) -> Tuple[AuthClientCredential, str]: + def new(cls, auth_client: AuthClient) -> tuple[AuthClientCredential, str]: """ Create a new client credential and return (cred, secret). @@ -413,23 +371,22 @@ class AuthCode(ScopeMixin, BaseMixin, Model): """Short-lived authorization tokens.""" __tablename__ = 'auth_code' - __allow_unmapped__ = True - user_id: Mapped[int] = sa.orm.mapped_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] = relationship(User, foreign_keys=[user_id]) + 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] = relationship( AuthClient, foreign_keys=[auth_client_id], - backref=sa.orm.backref('authcodes', cascade='all'), + backref=backref('authcodes', cascade='all'), ) - user_session_id: Mapped[Optional[int]] = sa.orm.mapped_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]] = relationship(UserSession) + login_session: Mapped[LoginSession | None] = relationship(LoginSession) code: Mapped[str] = sa.orm.mapped_column( sa.String(44), default=newsecret, nullable=False ) @@ -443,12 +400,12 @@ def is_valid(self) -> bool: return not self.used and self.created_at >= utcnow() - timedelta(minutes=3) @classmethod - def all_for(cls, user: User) -> Query[AuthCode]: - """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 @@ -459,20 +416,21 @@ 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.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) - user: Mapped[Optional[User]] = 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.orm.mapped_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( - 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 @@ -482,7 +440,7 @@ class AuthToken(ScopeMixin, BaseMixin, Model): auth_client: Mapped[AuthClient] = with_roles( relationship( AuthClient, - backref=sa.orm.backref('authtokens', lazy='dynamic', cascade='all'), + backref=backref('authtokens', lazy='dynamic', cascade='all'), ), read={'owner'}, ) @@ -503,23 +461,23 @@ class AuthToken(ScopeMixin, BaseMixin, Model): # 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) @@ -530,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)) @@ -542,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() ) @@ -559,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 @@ -578,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. @@ -612,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 @@ -627,62 +583,66 @@ 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(cls, users: Union[Query, Sequence[User]]) -> List[AuthToken]: # noqa: A003 - """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) -> Query[AuthToken]: - """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, Model): - """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.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user = 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.orm.mapped_column( @@ -692,7 +652,7 @@ class AuthClientUserPermissions(BaseMixin, Model): 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'}}, ) @@ -701,23 +661,21 @@ class AuthClientUserPermissions(BaseMixin, Model): '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(' ')) @@ -728,24 +686,24 @@ 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) -> Query[AuthClientUserPermissions]: - """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) -> Query[AuthClientUserPermissions]: + 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) @@ -756,13 +714,12 @@ 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.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.orm.mapped_column( @@ -772,7 +729,7 @@ class AuthClientTeamPermissions(BaseMixin, Model): 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'}}, ) @@ -793,7 +750,7 @@ 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 @@ -801,12 +758,12 @@ def get( @classmethod def all_for( - cls, auth_client: AuthClient, user: User - ) -> Query[AuthClientUserPermissions]: - """Get all permissions for the specified user via their teams.""" + 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 @@ -815,14 +772,14 @@ def all_forclient(cls, auth_client: AuthClient) -> Query[AuthClientTeamPermissio 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 2965fdfc6..b10eb07c9 100644 --- a/funnel/models/comment.py +++ b/funnel/models/comment.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime -from typing import List, Optional, Sequence, Set, Union +from typing import Any from werkzeug.utils import cached_property @@ -15,17 +16,28 @@ 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'] @@ -76,7 +88,6 @@ class SET_TYPE: # noqa: N801 class Commentset(UuidMixin, BaseMixin, Model): __tablename__ = 'commentset' - __allow_unmapped__ = True #: Commentset state code _state = sa.orm.mapped_column( 'state', @@ -88,7 +99,7 @@ class Commentset(UuidMixin, BaseMixin, Model): #: Commentset state manager state = StateManager('_state', COMMENTSET_STATE, doc="Commentset state") #: Type of parent object - settype: Mapped[Optional[int]] = with_roles( + settype: Mapped[int | None] = with_roles( sa.orm.mapped_column('type', sa.Integer, nullable=True), read={'all'}, datasets={'primary'}, @@ -100,7 +111,7 @@ class Commentset(UuidMixin, BaseMixin, Model): datasets={'primary'}, ) #: Timestamp of last comment, for ordering. - last_comment_at: Mapped[Optional[datetime]] = with_roles( + last_comment_at: Mapped[datetime | None] = with_roles( sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'}, datasets={'primary'}, @@ -136,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__ @@ -155,7 +166,7 @@ def last_comment(self): with_roles(last_comment, read={'all'}, datasets={'primary'}) def roles_for( - self, actor: Optional[User] = None, anchors: Sequence = () + self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: roles = super().roles_for(actor, anchors) parent_roles = self.parent.roles_for(actor, anchors) @@ -166,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: @@ -174,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, @@ -196,12 +207,13 @@ def enable_comments(self): class Comment(UuidMixin, BaseMixin, Model): __tablename__ = 'comment' - __allow_unmapped__ = True - user_id = sa.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) - _user: Mapped[Optional[User]] = with_roles( + posted_by_id: Mapped[int | None] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=True + ) + _posted_by: Mapped[Account | None] = with_roles( relationship( - User, backref=sa.orm.backref('comments', lazy='dynamic', cascade='all') + Account, backref=backref('comments', lazy='dynamic', cascade='all') ), grants={'author'}, ) @@ -211,7 +223,7 @@ class Comment(UuidMixin, BaseMixin, Model): 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'}}, ) @@ -219,8 +231,8 @@ class Comment(UuidMixin, BaseMixin, Model): in_reply_to_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('comment.id'), nullable=True ) - replies: Mapped[List[Comment]] = relationship( - 'Comment', backref=sa.orm.backref('in_reply_to', remote_side='Comment.id') + replies: Mapped[list[Comment]] = relationship( + 'Comment', backref=backref('in_reply_to', remote_side='Comment.id') ) _message, message_text, message_html = MarkdownCompositeBasic.create( @@ -269,7 +281,7 @@ class Comment(UuidMixin, BaseMixin, Model): 'read': {'created_at', 'urls', 'uuid_b58', 'has_replies'}, 'call': {'state', 'commentset', 'view_for', 'url_for'}, }, - 'replied_to_commenter': {'granted_via': {'in_reply_to': '_user'}}, + 'replied_to_commenter': {'granted_via': {'in_reply_to': '_posted_by'}}, } __datasets__ = { @@ -284,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 @@ -298,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.inplace.setter - def _user_setter(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.inplace.expression + @posted_by.inplace.expression @classmethod - def _user_expression(cls) -> sa.orm.InstrumentedAttribute[Optional[User]]: + def _posted_by_expression(cls) -> sa.orm.InstrumentedAttribute[Account | None]: """Return SQL Expression.""" - return cls._user + 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'} + ) @hybrid_property - def message(self) -> Union[MessageComposite, MarkdownCompositeBasic]: + def message(self) -> MessageComposite | MarkdownCompositeBasic: """Return the message of the comment if not deleted or removed.""" return ( message_deleted @@ -330,8 +346,8 @@ def message(self) -> Union[MessageComposite, MarkdownCompositeBasic]: else self._message ) - @message.inplace.setter # type: ignore[arg-type] - def _message_setter(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] @@ -356,21 +372,21 @@ def title(self) -> str: obj = self.commentset.parent if obj is not None: return _("{user} commented on {obj}").format( - user=self.user.pickername, obj=obj.title + user=self.posted_by.pickername, obj=obj.title ) - return _("{user} commented").format(user=self.user.pickername) + return _("{account} commented").format(account=self.posted_by.pickername) with_roles(title, read={'all'}, datasets={'primary', 'related', 'json'}) @property - def badges(self) -> Set[str]: + def badges(self) -> set[str]: badges = set() roles = set() if self.commentset.project is not None: - roles = self.commentset.project.roles_for(self._user) + roles = self.commentset.project.roles_for(self._posted_by) elif self.commentset.proposal is not None: - roles = self.commentset.proposal.project.roles_for(self._user) - if 'submitter' in self.commentset.proposal.roles_for(self._user): + roles = self.commentset.proposal.project.roles_for(self._posted_by) + if 'submitter' in self.commentset.proposal.roles_for(self._posted_by): badges.add(_("Submitter")) if 'editor' in roles: if 'promoter' in roles: @@ -387,7 +403,7 @@ def badges(self) -> Set[str]: def delete(self) -> None: """Delete this comment.""" if len(self.replies) > 0: - self.user = None + self.posted_by = None self.message = '' else: if self.in_reply_to and self.in_reply_to.state.DELETED: @@ -409,7 +425,7 @@ def mark_not_spam(self) -> None: """Mark this comment as not spam.""" def roles_for( - self, actor: Optional[User] = None, anchors: Sequence = () + self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: roles = super().roles_for(actor, anchors) roles.add('reader') diff --git a/funnel/models/commentset_membership.py b/funnel/models/commentset_membership.py index 615775e53..4becbd934 100644 --- a/funnel/models/commentset_membership.py +++ b/funnel/models/commentset_membership.py @@ -2,20 +2,18 @@ from __future__ import annotations -from typing import Set - from werkzeug.utils import cached_property from coaster.sqlalchemy import DynamicAssociationProxy, with_roles -from . import DynamicMapped, Mapped, Model, Query, db, relationship, 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'] @@ -24,15 +22,14 @@ 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', @@ -48,7 +45,7 @@ class CommentsetMembership(ImmutableUserMembershipMixin, Model): ) commentset: Mapped[Commentset] = relationship( Commentset, - backref=sa.orm.backref( + backref=backref( 'subscriber_memberships', lazy='dynamic', cascade='all', @@ -77,7 +74,7 @@ class CommentsetMembership(ImmutableUserMembershipMixin, Model): ) @cached_property - def offered_roles(self) -> Set[str]: + def offered_roles(self) -> set[str]: """ Roles offered by this membership record. @@ -86,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[CommentsetMembership]: + 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) @@ -117,13 +114,13 @@ def for_user(cls, user: User) -> Query[CommentsetMembership]: ) -@reopen(User) -class __User: +@reopen(Account) +class __Account: active_commentset_memberships: DynamicMapped[CommentsetMembership] = relationship( CommentsetMembership, lazy='dynamic', primaryjoin=sa.and_( - CommentsetMembership.user_id == User.id, + CommentsetMembership.member_id == Account.id, CommentsetMembership.is_active, ), viewonly=True, @@ -158,26 +155,26 @@ class __Commentset: ), 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) @@ -188,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 2519bd9f9..c9e18974b 100644 --- a/funnel/models/contact_exchange.py +++ b/funnel/models/contact_exchange.py @@ -2,10 +2,10 @@ from __future__ import annotations +from collections.abc import Collection, Sequence from dataclasses import dataclass from datetime import date as date_type, datetime from itertools import groupby -from typing import Collection, List, Optional, Sequence, Tuple from uuid import UUID from pytz import timezone @@ -14,11 +14,20 @@ from coaster.sqlalchemy import LazyRoleSet from coaster.utils import uuid_to_base58 -from ..typing import OptionalMigratedTables -from . import Mapped, Model, Query, RoleMixin, TimestampMixin, db, relationship, 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'] @@ -48,14 +57,13 @@ 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.orm.mapped_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] = relationship( - User, - backref=sa.orm.backref( + account: Mapped[Account] = relationship( + Account, + backref=backref( 'scanned_contacts', lazy='dynamic', order_by='ContactExchange.scanned_at.desc()', @@ -71,7 +79,7 @@ class ContactExchange(TimestampMixin, RoleMixin, Model): ) 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.orm.mapped_column( @@ -85,7 +93,7 @@ class ContactExchange(TimestampMixin, RoleMixin, Model): __roles__ = { 'owner': { 'read': { - 'user', + 'account', 'ticket_participant', 'scanned_at', 'description', @@ -93,39 +101,37 @@ class ContactExchange(TimestampMixin, RoleMixin, Model): }, '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: Sequence = () + 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: User, archived: bool = False - ) -> List[Tuple[ProjectId, List[DateCountContacts]]]: + 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'), @@ -136,7 +142,7 @@ def grouped_counts_for( ).filter( cls.ticket_participant_id == TicketParticipant.id, TicketParticipant.project_id == Project.id, - cls.user == user, + cls.account == account, ) if not archived: @@ -195,7 +201,7 @@ def grouped_counts_for( # 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 @@ -224,7 +230,7 @@ def grouped_counts_for( 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 @@ -246,11 +252,11 @@ def grouped_counts_for( @classmethod def contacts_for_project_and_date( - cls, user: User, project: Project, date: date_type, archived: bool = 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 @@ -273,11 +279,11 @@ def contacts_for_project_and_date( @classmethod def contacts_for_project( - cls, user: User, project: Project, archived: bool = False + 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, @@ -289,4 +295,4 @@ def contacts_for_project( 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 e52363af6..d51f79b92 100644 --- a/funnel/models/draft.py +++ b/funnel/models/draft.py @@ -2,7 +2,6 @@ from __future__ import annotations -from typing import Optional, Union from uuid import UUID from werkzeug.datastructures import MultiDict @@ -19,15 +18,15 @@ class Draft(NoIdMixin, Model): 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[Optional[types.jsonb_dict]] # Optional only when instance is new - revision: Mapped[Optional[UUID]] + body: Mapped[types.jsonb_dict | None] # Optional only when instance is new + revision: Mapped[UUID | None] @property def formdata(self) -> MultiDict: return MultiDict(self.body.get('form', {}) if self.body is not None else {}) @formdata.setter - def formdata(self, value: Union[MultiDict, dict]) -> None: + 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 b42ebcd1d..986f41be2 100644 --- a/funnel/models/email_address.py +++ b/funnel/models/email_address.py @@ -4,8 +4,7 @@ import hashlib import unicodedata -from typing import TYPE_CHECKING, Any, List, Optional, Set, Type, Union, cast, overload -from typing_extensions import Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload import base58 import idna @@ -14,7 +13,6 @@ from sqlalchemy import event, inspect 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 coaster.sqlalchemy import StateManager, auto_init_default, immutable, with_roles @@ -65,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,11 +153,12 @@ 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. @@ -170,7 +169,7 @@ class EmailAddress(BaseMixin, Model): 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. @@ -181,14 +180,13 @@ class EmailAddress(BaseMixin, Model): """ __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 @@ -301,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. @@ -336,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 @@ -351,6 +348,12 @@ 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})' @@ -378,7 +381,7 @@ def is_exclusive(self) -> bool: for related_obj in getattr(self, backref_name) ) - def is_available_for(self, owner: Optional[User]) -> bool: + 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): @@ -432,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 @@ -450,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`. @@ -486,7 +489,7 @@ def get_filter( def get( cls, email: str, - ) -> Optional[EmailAddress]: + ) -> EmailAddress | None: ... @overload @@ -495,7 +498,7 @@ def get( cls, *, blake2b160: bytes, - ) -> Optional[EmailAddress]: + ) -> EmailAddress | None: ... @overload @@ -504,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. @@ -526,7 +529,7 @@ def get( @classmethod def get_canonical( - cls, email: str, is_blocked: Optional[bool] = None + 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( 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[User], email: str) -> EmailAddress: + def add_for(cls, owner: Account | None, email: str) -> EmailAddress: """ Create a new :class:`EmailAddress` after validation. @@ -598,11 +601,11 @@ def add_for(cls, owner: Optional[User], email: str) -> EmailAddress: @classmethod def validate_for( cls, - owner: Optional[User], + owner: Account | None, email: str, check_dns: bool = False, new: bool = False, - ) -> Optional[ + ) -> ( Literal[ 'taken', 'nomx', @@ -612,8 +615,9 @@ def validate_for( 'invalid', 'nullmx', 'blocked', - ], - ]: + ] + | None + ): """ Validate whether the email address is available to the proposed owner. @@ -672,7 +676,7 @@ def validate_for( @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. @@ -704,18 +708,18 @@ 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[Optional[int]]: + def email_address_id(cls) -> Mapped[int | None]: """Foreign key to email_address table.""" return sa.orm.mapped_column( sa.Integer, @@ -736,7 +740,7 @@ def email_address(cls) -> Mapped[EmailAddress]: return relationship(EmailAddress, backref=backref_name) @property - def email(self) -> Optional[str]: + def email(self) -> str | None: """ Shorthand for ``self.email_address.email``. @@ -753,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: @@ -780,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 @@ -867,8 +871,8 @@ def _setup_refcount_events() -> None: def _email_address_mixin_set_validator( target: EmailAddressMixin, - value: Optional[EmailAddress], - old_value: Optional[EmailAddress], + value: EmailAddress | None, + old_value: EmailAddress | None, _initiator: Any, ) -> None: if value != old_value and target.__email_for__: @@ -881,11 +885,11 @@ def _email_address_mixin_set_validator( @event.listens_for(EmailAddressMixin, 'mapper_configured', propagate=True) def _email_address_mixin_configure_events( - _mapper: Any, cls: Type[EmailAddressMixin] + _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 .user import User + from .account import Account diff --git a/funnel/models/geoname.py b/funnel/models/geoname.py index 6531ae042..b4726675f 100644 --- a/funnel/models/geoname.py +++ b/funnel/models/geoname.py @@ -3,8 +3,9 @@ from __future__ import annotations import re +from collections.abc import Collection from decimal import Decimal -from typing import Collection, Dict, List, Optional, Union, cast +from typing import cast from sqlalchemy.dialects.postgresql import ARRAY @@ -16,6 +17,7 @@ GeonameModel, Mapped, Query, + backref, db, relationship, sa, @@ -46,32 +48,32 @@ class GeoCountryInfo(BaseNameMixin, GeonameModel): __tablename__ = 'geo_country_info' geonameid: Mapped[int] = sa.orm.synonym('id') - geoname: Mapped[Optional[GeoName]] = relationship( + geoname: Mapped[GeoName | None] = relationship( 'GeoName', uselist=False, primaryjoin='GeoCountryInfo.id == foreign(GeoName.id)', backref='has_country', ) - iso_alpha2: Mapped[Optional[types.char2]] = sa.orm.mapped_column( + iso_alpha2: Mapped[types.char2 | None] = sa.orm.mapped_column( sa.CHAR(2), unique=True ) - iso_alpha3: Mapped[Optional[types.char3]] = sa.orm.mapped_column(unique=True) - iso_numeric: Mapped[Optional[int]] - fips_code: Mapped[Optional[types.str3]] - capital: Mapped[Optional[str]] - area_in_sqkm: Mapped[Optional[Decimal]] - population: Mapped[Optional[types.bigint]] - continent: Mapped[Optional[types.char2]] - tld: Mapped[Optional[types.str3]] - currency_code: Mapped[Optional[types.char3]] - currency_name: Mapped[Optional[str]] - phone: Mapped[Optional[types.str16]] - postal_code_format: Mapped[Optional[types.unicode]] - postal_code_regex: Mapped[Optional[types.unicode]] - languages: Mapped[Optional[List[str]]] = sa.orm.mapped_column( + 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[Optional[List[str]]] = sa.orm.mapped_column( + neighbours: Mapped[list[str] | None] = sa.orm.mapped_column( ARRAY(sa.CHAR(2), dimensions=1) ) equivalent_fips_code: Mapped[types.str3] @@ -93,7 +95,6 @@ class GeoAdmin1Code(BaseMixin, GeonameModel): """Geoname record for 1st level administrative division (state, province).""" __tablename__ = 'geo_admin1_code' - __allow_unmapped__ = True geonameid: Mapped[int] = sa.orm.synonym('id') geoname: Mapped[GeoName] = relationship( @@ -108,7 +109,7 @@ class GeoAdmin1Code(BaseMixin, GeonameModel): country_id = sa.orm.mapped_column( 'country', sa.CHAR(2), sa.ForeignKey('geo_country_info.iso_alpha2') ) - country: Mapped[Optional[GeoCountryInfo]] = relationship('GeoCountryInfo') + country: Mapped[GeoCountryInfo | None] = relationship('GeoCountryInfo') admin1_code = sa.orm.mapped_column(sa.Unicode) def __repr__(self) -> str: @@ -120,7 +121,6 @@ class GeoAdmin2Code(BaseMixin, GeonameModel): """Geoname record for 2nd level administrative division (district, county).""" __tablename__ = 'geo_admin2_code' - __allow_unmapped__ = True geonameid: Mapped[int] = sa.orm.synonym('id') geoname: Mapped[GeoName] = relationship( @@ -135,7 +135,7 @@ class GeoAdmin2Code(BaseMixin, GeonameModel): country_id = sa.orm.mapped_column( 'country', sa.CHAR(2), sa.ForeignKey('geo_country_info.iso_alpha2') ) - country: Mapped[Optional[GeoCountryInfo]] = relationship('GeoCountryInfo') + country: Mapped[GeoCountryInfo | None] = relationship('GeoCountryInfo') admin1_code = sa.orm.mapped_column(sa.Unicode) admin2_code = sa.orm.mapped_column(sa.Unicode) @@ -148,7 +148,6 @@ class GeoName(BaseNameMixin, GeonameModel): """Geographical name record.""" __tablename__ = 'geo_name' - __allow_unmapped__ = True geonameid: Mapped[int] = sa.orm.synonym('id') ascii_title = sa.orm.mapped_column(sa.Unicode) @@ -159,10 +158,10 @@ class GeoName(BaseNameMixin, GeonameModel): country_id = sa.orm.mapped_column( 'country', sa.CHAR(2), sa.ForeignKey('geo_country_info.iso_alpha2') ) - country: Mapped[Optional[GeoCountryInfo]] = relationship('GeoCountryInfo') + country: Mapped[GeoCountryInfo | None] = relationship('GeoCountryInfo') cc2 = sa.orm.mapped_column(sa.Unicode) admin1 = sa.orm.mapped_column(sa.Unicode) - admin1_ref: Mapped[Optional[GeoAdmin1Code]] = relationship( + admin1_ref: Mapped[GeoAdmin1Code | None] = relationship( 'GeoAdmin1Code', uselist=False, primaryjoin='and_(GeoName.country_id == foreign(GeoAdmin1Code.country_id), ' @@ -172,12 +171,12 @@ class GeoName(BaseNameMixin, GeonameModel): admin1_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('geo_admin1_code.id'), nullable=True ) - admin1code: Mapped[Optional[GeoAdmin1Code]] = relationship( + admin1code: Mapped[GeoAdmin1Code | None] = relationship( 'GeoAdmin1Code', uselist=False, foreign_keys=[admin1_id] ) admin2 = sa.orm.mapped_column(sa.Unicode) - admin2_ref: Mapped[Optional[GeoAdmin2Code]] = relationship( + admin2_ref: Mapped[GeoAdmin2Code | None] = relationship( 'GeoAdmin2Code', uselist=False, primaryjoin='and_(GeoName.country_id == foreign(GeoAdmin2Code.country_id), ' @@ -188,7 +187,7 @@ class GeoName(BaseNameMixin, GeonameModel): admin2_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('geo_admin2_code.id'), nullable=True ) - admin2code: Mapped[Optional[GeoAdmin2Code]] = relationship( + admin2code: Mapped[GeoAdmin2Code | None] = relationship( 'GeoAdmin2Code', uselist=False, foreign_keys=[admin2_id] ) @@ -325,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: @@ -385,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. @@ -433,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. @@ -452,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: @@ -527,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), @@ -554,7 +553,7 @@ def parse_locations( return results @classmethod - def autocomplete(cls, prefix: str, lang: Optional[str] = None) -> Query[GeoName]: + def autocomplete(cls, prefix: str, lang: str | None = None) -> Query[GeoName]: """ Autocomplete a geoname record. @@ -581,14 +580,13 @@ class GeoAltName(BaseMixin, GeonameModel): """Additional names for any :class:`GeoName`.""" __tablename__ = 'geo_alt_name' - __allow_unmapped__ = True 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.orm.mapped_column(sa.Unicode, nullable=True, index=True) title = sa.orm.mapped_column(sa.Unicode, nullable=False) diff --git a/funnel/models/helpers.py b/funnel/models/helpers.py index ba1a9e2f9..3fb9d038e 100644 --- a/funnel/models/helpers.py +++ b/funnel/models/helpers.py @@ -4,22 +4,10 @@ import os.path import re +from collections.abc import Callable, Iterable from dataclasses import dataclass from textwrap import dedent -from typing import ( - Any, - Callable, - ClassVar, - Dict, - Iterable, - List, - Optional, - Set, - Tuple, - Type, - TypeVar, - cast, -) +from typing import Any, ClassVar, TypeVar, cast from better_profanity import profanity from furl import furl @@ -47,7 +35,7 @@ 'add_search_trigger', 'visual_field_delimiter', 'valid_name', - 'valid_username', + 'valid_account_name', 'quote_autocomplete_like', 'quote_autocomplete_tsquery', 'ImgeeFurl', @@ -58,7 +46,7 @@ 'MarkdownCompositeInline', ] -RESERVED_NAMES: Set[str] = { +RESERVED_NAMES: set[str] = { '_baseframe', 'about', 'account', @@ -164,7 +152,7 @@ class PasswordCheckType: is_weak: bool score: int # One of 0, 1, 2, 3, 4 warning: str - suggestions: List[str] + suggestions: list[str] #: Minimum length for a password @@ -177,7 +165,7 @@ class PasswordCheckType: def check_password_strength( - password: str, user_inputs: Optional[Iterable[str]] = None + password: str, user_inputs: Iterable[str] | None = None ) -> PasswordCheckType: """Check the strength of a password using zxcvbn.""" result = zxcvbn(password, user_inputs) @@ -195,7 +183,7 @@ def check_password_strength( # re.IGNORECASE needs re.ASCII because of a quirk in the characters it matches. # https://docs.python.org/3/library/re.html#re.I -_username_valid_re = re.compile('^[a-z0-9][a-z0-9_]*$', re.I | re.A) +_account_name_valid_re = re.compile('^[a-z0-9][a-z0-9_]*$', re.I | re.A) _name_valid_re = re.compile('^[a-z0-9]([a-z0-9-]*[a-z0-9])?$', re.A) @@ -212,7 +200,7 @@ def check_password_strength( visual_field_delimiter = ' ¦ ' -def add_to_class(cls: Type, name: Optional[str] = None) -> Callable[[T], T]: +def add_to_class(cls: type, name: str | None = None) -> Callable[[T], T]: """ Add a new method to a class via a decorator. Takes an optional attribute name. @@ -229,7 +217,7 @@ def existing_class_new_property(self): """ def decorator(attr: T) -> T: - use_name: Optional[str] = name or getattr(attr, '__name__', None) + use_name: str | None = name or getattr(attr, '__name__', None) if not use_name: # pragma: no cover # None or '' not allowed raise ValueError(f"Could not determine name for {attr!r}") @@ -317,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: @@ -369,7 +357,11 @@ def quote_autocomplete_like(prefix: str, midway: bool = 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() @@ -391,7 +383,7 @@ def quote_autocomplete_tsquery(prefix: str) -> TSQUERY: ) -def add_search_trigger(model: Type[Model], column_name: str) -> 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. @@ -466,13 +458,14 @@ class MyModel(Model): 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__), ) ) @@ -516,7 +509,7 @@ class MessageComposite: :param tag: Optional wrapper tag for HTML rendering """ - def __init__(self, text: str, tag: Optional[str] = None) -> None: + def __init__(self, text: str, tag: str | None = None) -> None: self.text = text self.tag = tag @@ -544,7 +537,7 @@ def __html_format__(self, format_spec: str) -> str: 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__()} @@ -552,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. @@ -595,15 +588,15 @@ class MarkdownCompositeBase(MutableComposite): config: ClassVar[MarkdownConfig] - def __init__(self, text: Optional[str], html: Optional[str] = None) -> 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: Optional[str] = html + self._html: str | None = html - def __composite_values__(self) -> Tuple[Optional[str], Optional[str]]: + def __composite_values__(self) -> tuple[str | None, str | None]: """Return composite values for SQLAlchemy.""" return (self._text, self._html) @@ -634,23 +627,23 @@ def __html_format__(self, format_spec: str) -> str: # Return a Markup string of the HTML @property - def html(self) -> Optional[Markup]: + 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) -> Optional[str]: + def text(self) -> str | None: """Return text as a property.""" return self._text @text.setter - def text(self, value: Optional[str]) -> None: + 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} @@ -670,12 +663,12 @@ def __ne__(self, other: Any) -> bool: # tested here as we don't use them. # https://docs.sqlalchemy.org/en/13/orm/extensions/mutable.html#id1 - def __getstate__(self) -> Tuple[Optional[str], Optional[str]]: + 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: Tuple[Optional[str], Optional[str]]) -> None: + def __setstate__(self, state: tuple[str | None, str | None]) -> None: """Set state from pickle.""" # Set state from pickle self._text, self._html = state @@ -686,18 +679,21 @@ def __bool__(self) -> bool: return bool(self._text) @classmethod - def coerce(cls: Type[_MC], key: str, value: Any) -> _MC: + 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: Type[_MC], + cls: type[_MC], name: str, deferred: bool = False, - deferred_group: Optional[str] = None, + deferred_group: str | None = None, **kwargs, - ) -> Tuple[sa.orm.Composite[_MC], Mapped[str], Mapped[str]]: + ) -> tuple[sa.orm.Composite[_MC], Mapped[str], Mapped[str]]: """Create a composite column and backing individual columns.""" col_text = sa.orm.mapped_column( name + '_text', diff --git a/funnel/models/label.py b/funnel/models/label.py index 02c417e63..cb586b160 100644 --- a/funnel/models/label.py +++ b/funnel/models/label.py @@ -2,9 +2,7 @@ from __future__ import annotations -from typing import List, Union - -from sqlalchemy.ext.orderinglist import ordering_list +from sqlalchemy.ext.orderinglist import OrderingList, ordering_list from coaster.sqlalchemy import with_roles @@ -46,7 +44,6 @@ class Label(BaseScopedNameMixin, Model): __tablename__ = 'label' - __allow_unmapped__ = True project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False @@ -72,7 +69,7 @@ class Label(BaseScopedNameMixin, Model): remote_side='Label.id', back_populates='options' ) # See https://docs.sqlalchemy.org/en/13/orm/self_referential.html - options: Mapped[List[Label]] = relationship( + options: Mapped[OrderingList[Label]] = relationship( back_populates='main_label', order_by='Label.seq', passive_deletes=True, @@ -130,7 +127,7 @@ class Label(BaseScopedNameMixin, Model): ) #: Proposals that this label is attached to - proposals: Mapped[List[Proposal]] = relationship( + proposals: Mapped[list[Proposal]] = relationship( Proposal, secondary=proposal_label, back_populates='labels' ) @@ -326,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, @@ -390,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) @@ -401,7 +396,7 @@ def __get__( @reopen(Project) class __Project: - labels: Mapped[List[Label]] = relationship( + labels: Mapped[list[Label]] = relationship( Label, primaryjoin=sa.and_( Label.project_id == Project.id, @@ -411,7 +406,7 @@ class __Project: order_by=Label.seq, viewonly=True, ) - all_labels: Mapped[List[Label]] = relationship( + all_labels: Mapped[list[Label]] = relationship( Label, collection_class=ordering_list('seq', count_from=1), back_populates='project', @@ -423,7 +418,7 @@ class __Proposal: #: For reading and setting labels from the edit form formlabels = ProposalLabelProxy() - labels: Mapped[List[Label]] = with_roles( + labels: Mapped[list[Label]] = with_roles( relationship(Label, secondary=proposal_label, back_populates='proposals'), read={'all'}, ) diff --git a/funnel/models/user_session.py b/funnel/models/login_session.py similarity index 61% rename from funnel/models/user_session.py rename to funnel/models/login_session.py index fd30300df..e1d628cc5 100644 --- a/funnel/models/user_session.py +++ b/funnel/models/login_session.py @@ -3,48 +3,56 @@ from __future__ import annotations from datetime import timedelta -from typing import Optional from coaster.utils import utcnow from ..signals import session_revoked -from . import BaseMixin, DynamicMapped, Mapped, Model, UuidMixin, relationship, sa +from . import ( + BaseMixin, + DynamicMapped, + Mapped, + Model, + UuidMixin, + backref, + relationship, + sa, +) +from .account import Account from .helpers import reopen -from .user import User __all__ = [ - 'UserSession', - 'UserSessionError', - 'UserSessionExpiredError', - 'UserSessionRevokedError', - 'UserSessionInactiveUserError', - 'auth_client_user_session', - 'USER_SESSION_VALIDITY_PERIOD', + 'LoginSession', + 'LoginSessionError', + 'LoginSessionExpiredError', + 'LoginSessionRevokedError', + 'LoginSessionInactiveUserError', + 'auth_client_login_session', + 'LOGIN_SESSION_VALIDITY_PERIOD', ] -class UserSessionError(Exception): +class LoginSessionError(Exception): """Base exception for user session errors.""" -class UserSessionExpiredError(UserSessionError): +class LoginSessionExpiredError(LoginSessionError): """This user session has expired and cannot be marked as currently active.""" -class UserSessionRevokedError(UserSessionError): +class LoginSessionRevokedError(LoginSessionError): """This user session has been revoked and cannot be marked as currently active.""" -class UserSessionInactiveUserError(UserSessionError): +class LoginSessionInactiveUserError(LoginSessionError): """This user is not in ACTIVE state and cannot have a currently active session.""" -USER_SESSION_VALIDITY_PERIOD = timedelta(days=365) +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_user_session = sa.Table( - 'auth_client_user_session', +auth_client_login_session = sa.Table( + 'auth_client_login_session', Model.metadata, sa.Column( 'auth_client_id', @@ -54,9 +62,9 @@ class UserSessionInactiveUserError(UserSessionError): primary_key=True, ), sa.Column( - 'user_session_id', + 'login_session_id', sa.Integer, - sa.ForeignKey('user_session.id'), + sa.ForeignKey('login_session.id'), nullable=False, primary_key=True, ), @@ -75,13 +83,15 @@ class UserSessionInactiveUserError(UserSessionError): ) -class UserSession(UuidMixin, BaseMixin, Model): - __tablename__ = 'user_session' - __allow_unmapped__ = True +class LoginSession(UuidMixin, BaseMixin, Model): + __tablename__ = 'login_session' - user_id = sa.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = relationship( - User, backref=sa.orm.backref('all_user_sessions', cascade='all', lazy='dynamic') + 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 @@ -126,11 +136,11 @@ def revoke(self) -> None: session_revoked.send(self) @classmethod - def get(cls, buid: str) -> Optional[UserSession]: + 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) -> Optional[UserSession]: + def authenticate(cls, buid: str, silent: bool = False) -> LoginSession | None: """ Retrieve a user session that is supposed to be active. @@ -139,42 +149,42 @@ def authenticate(cls, buid: str, silent: bool = False) -> Optional[UserSession]: """ if silent: return ( - cls.query.join(User) + cls.query.join(Account) .filter( # Session key must match. cls.buid == buid, # Sessions are valid for one year... - cls.accessed_at > sa.func.utcnow() - USER_SESSION_VALIDITY_PERIOD, + cls.accessed_at > sa.func.utcnow() - LOGIN_SESSION_VALIDITY_PERIOD, # ...unless explicitly revoked (or user logged out). cls.revoked_at.is_(None), - # User account must be active - User.state.ACTIVE, + # Account must be active + Account.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: DynamicMapped[UserSession] = relationship( - UserSession, + 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_( - UserSession.user_id == User.id, - UserSession.accessed_at > sa.func.utcnow() - USER_SESSION_VALIDITY_PERIOD, - UserSession.revoked_at.is_(None), + LoginSession.account_id == Account.id, + LoginSession.accessed_at > sa.func.utcnow() - LOGIN_SESSION_VALIDITY_PERIOD, + LoginSession.revoked_at.is_(None), ), - order_by=UserSession.accessed_at.desc(), + order_by=LoginSession.accessed_at.desc(), viewonly=True, ) diff --git a/funnel/models/mailer.py b/funnel/models/mailer.py index ef8c71c78..035b8061b 100644 --- a/funnel/models/mailer.py +++ b/funnel/models/mailer.py @@ -3,12 +3,14 @@ from __future__ import annotations import re +from collections.abc import Collection, Iterator from datetime import datetime from enum import IntEnum -from typing import Any, Collection, Dict, Iterator, List, Optional, Set, Union +from typing import Any from uuid import UUID -from flask import Markup, escape, request +from flask import request +from markupsafe import Markup, escape from premailer import transform as email_transform from sqlalchemy.orm import defer @@ -27,13 +29,12 @@ relationship, sa, ) +from .account import Account from .helpers import reopen from .types import jsonb -from .user import User __all__ = [ 'MailerState', - 'User', 'Mailer', 'MailerDraft', 'MailerRecipient', @@ -71,8 +72,8 @@ class Mailer(BaseNameMixin, Model): __tablename__ = 'mailer' - user_uuid: Mapped[UUID] = sa.orm.mapped_column(sa.ForeignKey('user.uuid')) - user: Mapped[User] = relationship(User, back_populates='mailers') + 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 ) @@ -95,7 +96,7 @@ class Mailer(BaseNameMixin, Model): order_by='(MailerRecipient.draft_id, MailerRecipient._fullname,' ' MailerRecipient._firstname, MailerRecipient._lastname)', ) - drafts: Mapped[List[MailerDraft]] = relationship( + drafts: Mapped[list[MailerDraft]] = relationship( back_populates='mailer', cascade='all, delete-orphan', order_by='MailerDraft.url_id', @@ -127,7 +128,7 @@ def cc(self) -> str: return self._cc @cc.setter - def cc(self, value: Union[str, Collection[str]]) -> None: + def cc(self, value: str | Collection[str]) -> None: if isinstance(value, str): value = [ _l.strip() @@ -142,7 +143,7 @@ def bcc(self) -> str: return self._bcc @bcc.setter - def bcc(self, value: Union[str, Collection[str]]) -> None: + def bcc(self, value: str | Collection[str]) -> None: if isinstance(value, str): value = [ _l.strip() @@ -166,14 +167,14 @@ def recipients_iter(self) -> Iterator[MailerRecipient]: yield recipient def permissions( - self, actor: Optional[User], inherited: Optional[Set[str]] = None - ) -> Set[str]: + 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) -> Optional[MailerDraft]: + def draft(self) -> MailerDraft | None: if self.drafts: return self.drafts[-1] return None @@ -229,16 +230,16 @@ class MailerRecipient(BaseScopedIdMixin, Model): mailer: Mapped[Mailer] = relationship(Mailer, back_populates='recipients') parent: Mapped[Mailer] = sa.orm.synonym('mailer') - _fullname: Mapped[Optional[str]] = sa.orm.mapped_column( + _fullname: Mapped[str | None] = sa.orm.mapped_column( 'fullname', sa.Unicode(80), nullable=True ) - _firstname: Mapped[Optional[str]] = sa.orm.mapped_column( + _firstname: Mapped[str | None] = sa.orm.mapped_column( 'firstname', sa.Unicode(80), nullable=True ) - _lastname: Mapped[Optional[str]] = sa.orm.mapped_column( + _lastname: Mapped[str | None] = sa.orm.mapped_column( 'lastname', sa.Unicode(80), nullable=True ) - _nickname: Mapped[Optional[str]] = sa.orm.mapped_column( + _nickname: Mapped[str | None] = sa.orm.mapped_column( 'nickname', sa.Unicode(80), nullable=True ) @@ -260,13 +261,13 @@ class MailerRecipient(BaseScopedIdMixin, Model): opened: Mapped[bool] = sa.orm.mapped_column( sa.Boolean, nullable=False, default=False ) - opened_ipaddr: Mapped[Optional[str]] = sa.orm.mapped_column( + opened_ipaddr: Mapped[str | None] = sa.orm.mapped_column( sa.Unicode(45), nullable=True ) - opened_first_at: Mapped[Optional[datetime]] = sa.orm.mapped_column( + opened_first_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) - opened_last_at: Mapped[Optional[datetime]] = sa.orm.mapped_column( + opened_last_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) opened_count: Mapped[int] = sa.orm.mapped_column( @@ -278,30 +279,28 @@ class MailerRecipient(BaseScopedIdMixin, Model): sa.Unicode(44), nullable=False, default=newsecret, unique=True ) # Y/N/M response - rsvp: Mapped[Optional[str]] = sa.orm.mapped_column(sa.Unicode(1), nullable=True) + rsvp: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode(1), nullable=True) # Customised template for this recipient - subject: Mapped[Optional[str]] = sa.orm.mapped_column( - sa.Unicode(250), nullable=True - ) - template: Mapped[Optional[str]] = sa.orm.mapped_column( + 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[Optional[str]] = sa.orm.mapped_column( + rendered_text: Mapped[str | None] = sa.orm.mapped_column( sa.UnicodeText, nullable=True, deferred=True ) - rendered_html: Mapped[Optional[str]] = sa.orm.mapped_column( + 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[Optional[int]] = sa.orm.mapped_column( + draft_id: Mapped[int | None] = sa.orm.mapped_column( sa.ForeignKey('mailer_draft.id') ) - draft: Mapped[Optional[MailerDraft]] = relationship(MailerDraft) + draft: Mapped[MailerDraft | None] = relationship(MailerDraft) __table_args__ = (sa.UniqueConstraint('mailer_id', 'url_id'),) @@ -309,7 +308,7 @@ def __repr__(self) -> str: return f'' @property - def fullname(self) -> Optional[str]: + def fullname(self) -> str | None: """Recipient's fullname, constructed from first and last names if required.""" if self._fullname: return self._fullname @@ -323,11 +322,11 @@ def fullname(self) -> Optional[str]: return None @fullname.setter - def fullname(self, value: Optional[str]) -> None: + def fullname(self, value: str | None) -> None: self._fullname = value @property - def firstname(self) -> Optional[str]: + def firstname(self) -> str | None: if self._firstname: return self._firstname if self._fullname: @@ -335,11 +334,11 @@ def firstname(self) -> Optional[str]: return None @firstname.setter - def firstname(self, value: Optional[str]) -> None: + def firstname(self, value: str | None) -> None: self._firstname = value @property - def lastname(self) -> Optional[str]: + def lastname(self) -> str | None: if self._lastname: return self._lastname if self._fullname: @@ -347,15 +346,15 @@ def lastname(self) -> Optional[str]: return None @lastname.setter - def lastname(self, value: Optional[str]) -> None: + def lastname(self, value: str | None) -> None: self._lastname = value @property - def nickname(self) -> Optional[str]: + def nickname(self) -> str | None: return self._nickname or self.firstname @nickname.setter - def nickname(self, value: Optional[str]) -> None: + def nickname(self, value: str | None) -> None: self._nickname = value @property @@ -368,7 +367,7 @@ def email(self, value: str) -> None: self.md5sum = md5sum(value) @property - def revision_id(self) -> Optional[int]: + def revision_id(self) -> int | None: return self.draft.revision_id if self.draft else None def is_latest_draft(self) -> bool: @@ -376,7 +375,7 @@ def is_latest_draft(self) -> bool: return True return self.draft == self.mailer.draft() - def template_data(self) -> Dict[str, Any]: + def template_data(self) -> dict[str, Any]: tdata = { 'fullname': self.fullname, 'email': self.email, @@ -423,7 +422,7 @@ def custom_draft(self) -> bool: return self.draft is not None @classmethod - def custom_draft_in(cls, mailer: Mailer) -> List[MailerRecipient]: + def custom_draft_in(cls, mailer: Mailer) -> list[MailerRecipient]: return ( cls.query.filter( cls.mailer == mailer, @@ -448,8 +447,8 @@ def custom_draft_in(cls, mailer: Mailer) -> List[MailerRecipient]: ) -@reopen(User) -class __User: - mailers: Mapped[List[Mailer]] = relationship( +@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 86ad36214..345b9ac89 100644 --- a/funnel/models/membership_mixin.py +++ b/funnel/models/membership_mixin.py @@ -2,20 +2,9 @@ from __future__ import annotations +from collections.abc import Callable, Iterable from datetime import datetime as datetime_type -from typing import ( - TYPE_CHECKING, - Any, - Callable, - ClassVar, - Generic, - Iterable, - Optional, - Set, - Type, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar from sqlalchemy import event from sqlalchemy.sql.expression import ColumnElement @@ -25,7 +14,6 @@ from coaster.sqlalchemy import StateManager, immutable, with_roles from coaster.utils import LabeledEnum -from ..typing import OptionalMigratedTables from . import ( BaseMixin, Mapped, @@ -38,9 +26,8 @@ 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__ = [ @@ -54,7 +41,6 @@ MembershipType = TypeVar('MembershipType', bound='ImmutableMembershipMixin') FrozenAttributionType = TypeVar('FrozenAttributionType', bound='FrozenAttributionMixin') -SubjectType = Union[Mapped[User], Mapped[Profile]] # --- Enum ----------------------------------------------------------------------------- @@ -105,9 +91,7 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin): #: List of columns that will be copied into a new row when a membership is amended __data_columns__: ClassVar[Iterable[str]] = () #: Name of the parent id column, used in SQL constraints - parent_id_column: ClassVar[Optional[str]] - #: Subject of this membership (subclasses must define) - subject: SubjectType + parent_id_column: ClassVar[str | None] if TYPE_CHECKING: #: Subclass has a table name __tablename__: str @@ -115,11 +99,13 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin): #: subclasses) parent_id: Mapped[int] #: Parent object - parent: Mapped[Optional[Model]] + parent: Mapped[Model | None] + #: Subject of this membership (subclasses must define) + member: Mapped[Account] - #: Should an active membership record be revoked when the subject is soft-deleted? + #: 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` @@ -133,12 +119,12 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin): 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( + revoked_at: Mapped[datetime_type | None] = with_roles( sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), - read={'subject', 'editor'}, + read={'member', 'editor'}, ) #: Record type record_type: Mapped[int] = with_roles( @@ -150,33 +136,33 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin): 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.orm.mapped_column( - sa.Integer, sa.ForeignKey('user.id', ondelete='SET NULL'), nullable=True + 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 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[Optional[int]]: + def granted_by_id(cls) -> Mapped[int | None]: """ Id of user who assigned the membership. @@ -185,16 +171,16 @@ def granted_by_id(cls) -> Mapped[Optional[int]]: """ 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 relationship(User, foreign_keys=[cls.granted_by_id]) + return relationship(Account, foreign_keys=[cls.granted_by_id]) @hybrid_property def is_active(self) -> bool: @@ -212,39 +198,39 @@ def _is_active_expression(cls) -> sa.ColumnElement[bool]: 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( @@ -259,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: @@ -316,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__: @@ -357,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 @@ -383,27 +371,27 @@ class ImmutableUserMembershipMixin(ImmutableMembershipMixin): @declared_attr @classmethod - def user_id(cls) -> Mapped[int]: - """Foreign key column to user table.""" + 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 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 @@ -414,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', ), @@ -422,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', ), @@ -430,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 - ) - 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.orm.mapped_column( - sa.Integer, - sa.ForeignKey('profile.id', ondelete='CASCADE'), - nullable=False, - index=True, + """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) ) - @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 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_column 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() @@ -685,13 +561,14 @@ 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.orm.mapped_column( @@ -704,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 @@ -751,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( @@ -790,16 +664,12 @@ def commit(self) -> MembershipType: return self.membership -@event.listens_for(EnumerateMembershipsMixin, 'mapper_configured', propagate=True) -def _confirm_enumerated_mixins( - _mapper: Any, cls: Type[EnumerateMembershipsMixin] -) -> 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(cls, User): + if issubclass(cls, Account): expected_class = ImmutableUserMembershipMixin - elif issubclass(cls, Profile): - expected_class = ImmutableProfileMembershipMixin for source in ( cls.__active_membership_attrs__, cls.__noninvite_membership_attrs__, diff --git a/funnel/models/moderation.py b/funnel/models/moderation.py index f118d4c85..0ba9f69e2 100644 --- a/funnel/models/moderation.py +++ b/funnel/models/moderation.py @@ -6,19 +6,11 @@ from coaster.sqlalchemy import StateManager, with_roles from coaster.utils import LabeledEnum -from . import ( - BaseMixin, - Comment, - Mapped, - Model, - SiteMembership, - User, - UuidMixin, - db, - relationship, - 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'] @@ -30,7 +22,6 @@ class MODERATOR_REPORT_TYPE(LabeledEnum): # noqa: N801 class CommentModeratorReport(UuidMixin, BaseMixin, Model): __tablename__ = 'comment_moderator_report' - __allow_unmapped__ = True __uuid_primary_key__ = True comment_id = sa.orm.mapped_column( @@ -39,15 +30,15 @@ class CommentModeratorReport(UuidMixin, BaseMixin, Model): 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.orm.mapped_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] = 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.orm.mapped_column( sa.SmallInteger, @@ -65,7 +56,7 @@ class CommentModeratorReport(UuidMixin, BaseMixin, Model): __datasets__ = { 'primary': { 'comment', - 'user', + 'reported_by', 'report_type', 'reported_at', 'resolved_at', @@ -91,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 @@ -99,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), @@ -122,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 0114c0762..0abcb5a6c 100644 --- a/funnel/models/notification.py +++ b/funnel/models/notification.py @@ -76,31 +76,27 @@ 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_args, get_origin, get_original_bases +from typing_extensions import Protocol, get_original_bases from uuid import UUID, uuid4 from sqlalchemy import event @@ -118,7 +114,7 @@ ) from coaster.utils import LabeledEnum, uuid_from_base58, uuid_to_base58 -from ..typing import OptionalMigratedTables, T +from ..typing import T from . import ( BaseMixin, DynamicMapped, @@ -126,15 +122,17 @@ 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 .typing import UuidModelUnion -from .user import User, UserEmail, UserPhone __all__ = [ 'SMS_STATUS', @@ -144,7 +142,7 @@ 'Notification', 'PreviewNotification', 'NotificationPreferences', - 'UserNotification', + 'NotificationRecipient', 'NotificationFor', 'notification_type_registry', 'notification_web_types', @@ -163,9 +161,9 @@ #: 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, Type[Notification]] = {} +notification_type_registry: dict[str, type[Notification]] = {} #: Registry of notification types that allow web renders -notification_web_types: Set[str] = set() +notification_web_types: set[str] = set() @dataclass @@ -174,7 +172,7 @@ class NotificationCategory: priority_id: int title: str - available_for: Callable[[User], bool] + available_for: Callable[[Account], bool] #: Registry of notification categories @@ -240,13 +238,12 @@ 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 @@ -257,14 +254,14 @@ class SmsMessage(PhoneNumberMixin, BaseMixin, Model): 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) @@ -283,10 +280,10 @@ class NotificationType(Generic[_D, _F], Protocol): eventid_b58: str document: _D document_uuid: UUID - fragment: Optional[_F] - 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, Model, Generic[_D, _F]): @@ -302,7 +299,6 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): """ __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 @@ -312,12 +308,16 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): #: be shared across notifications, and will be used to enforce a limit of one #: instance of a UserNotification per-event rather than per-notification eventid: Mapped[UUID] = immutable( - sa.orm.mapped_column(sa.Uuid, primary_key=True, nullable=False, default=uuid4) + sa.orm.mapped_column( + postgresql.UUID, primary_key=True, nullable=False, default=uuid4 + ) ) #: Notification id id: Mapped[UUID] = immutable( # noqa: A003 - sa.orm.mapped_column(sa.Uuid, primary_key=True, nullable=False, default=uuid4) + sa.orm.mapped_column( + postgresql.UUID, primary_key=True, nullable=False, default=uuid4 + ) ) #: Default category of notification. Subclasses MUST override @@ -333,14 +333,14 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): pref_type: ClassVar[str] = '' #: Document model, must be specified in subclasses - document_model: ClassVar[Type[UuidModelUnion]] + 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[UuidModelUnion]]] = 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. @@ -355,7 +355,7 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): #: The preference context this notification is being served under. Users may have #: customized preferences per account (nee profile) or project - preference_context: Any = None + preference_context: ClassVar[Any] = None #: Notification type (identifier for subclass of :class:`NotificationType`) type_: Mapped[str] = immutable( @@ -363,24 +363,24 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): ) #: 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]] = relationship(User) + created_by: Mapped[Account | None] = relationship(Account) #: UUID of document that the notification refers to document_uuid: Mapped[UUID] = immutable( - sa.orm.mapped_column(sa.Uuid, nullable=False, index=True) + sa.orm.mapped_column(postgresql.UUID, nullable=False, index=True) ) #: Optional fragment within document that the notification refers to. This may be #: the document itself, or something within it, such as a comment. Notifications for #: multiple fragments are collapsed into a single notification - fragment_uuid: Mapped[Optional[UUID]] = immutable( - sa.orm.mapped_column(sa.Uuid, nullable=True) + fragment_uuid: Mapped[UUID | None] = immutable( + sa.orm.mapped_column(postgresql.UUID, nullable=True) ) __table_args__ = ( @@ -469,13 +469,13 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): 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__( # 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 @@ -537,8 +537,8 @@ def __init_subclass__( # pylint: disable=arguments-differ def __init__( self, - document: Optional[_D] = None, - fragment: Optional[_F] = None, + document: _D | None = None, + fragment: _F | None = None, **kwargs: Any, ) -> None: if document is not None: @@ -556,7 +556,7 @@ def __init__( 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) @@ -593,7 +593,7 @@ def document(self) -> _D: ) @cached_property - def fragment(self) -> Optional[_F]: + def fragment(self) -> _F | None: """ Retrieve the fragment within a document referenced by this Notification, if any. @@ -607,7 +607,7 @@ def fragment(self) -> Optional[_F]: 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. @@ -633,11 +633,11 @@ def allow_transport(cls, transport: str) -> bool: return getattr(cls, 'allow_' + transport) @property - def role_provider_obj(self) -> Union[_F, _D]: + def role_provider_obj(self) -> _F | _D: """Return fragment if exists, document otherwise, indicating role provider.""" 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. @@ -650,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: @@ -677,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 @@ -702,16 +704,16 @@ class PreviewNotification(NotificationType): NotificationFor( PreviewNotification(NotificationType, document, fragment, actor), - user + recipient ) """ def __init__( # pylint: disable=super-init-not-called self, - cls: Type[Notification], + cls: type[Notification], document: UuidModelUnion, - fragment: Optional[UuidModelUnion] = None, - user: Optional[User] = None, + fragment: UuidModelUnion | None = None, + user: Account | None = None, ) -> None: self.eventid = uuid4() self.id = uuid4() @@ -722,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[Mapped[Notification], Notification, PreviewNotification] + notification: Mapped[Notification] | Notification | PreviewNotification @cached_property def notification_type(self) -> str: @@ -753,14 +755,14 @@ def notification_pref_type(self) -> str: with_roles(notification_pref_type, read={'owner'}) @cached_property - def document(self) -> Optional[UuidModelUnion]: + 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[UuidModelUnion]: + def fragment(self) -> UuidModelUnion | None: """Fragment within this document that this notification is for.""" return self.notification.fragment @@ -785,47 +787,48 @@ def is_not_deleted(self, revoke: bool = False) -> bool: return False -class UserNotification(UserNotificationMixin, NoIdMixin, Model): +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( - relationship(User), read={'owner'}, grants={'owner'} + recipient: Mapped[Account] = with_roles( + relationship(Account), read={'owner'}, grants={'owner'} ) #: Random eventid, shared with the Notification instance eventid: Mapped[UUID] = with_roles( - immutable(sa.orm.mapped_column(sa.Uuid, primary_key=True, nullable=False)), + immutable( + sa.orm.mapped_column(postgresql.UUID, primary_key=True, nullable=False) + ), read={'owner'}, ) #: Id of notification that this user received (fkey in __table_args__ below) - notification_id: Mapped[UUID] = sa.orm.mapped_column(sa.Uuid, nullable=False) + notification_id: Mapped[UUID] = sa.orm.mapped_column( + postgresql.UUID, nullable=False + ) #: Notification that this user received notification: Mapped[Notification] = with_roles( - relationship( - Notification, backref=sa.orm.backref('recipients', lazy='dynamic') - ), + relationship(Notification, backref=backref('recipients', lazy='dynamic')), read={'owner'}, ) @@ -840,7 +843,7 @@ class UserNotification(UserNotificationMixin, NoIdMixin, Model): ) #: 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'}, ) @@ -850,35 +853,33 @@ class UserNotification(UserNotificationMixin, NoIdMixin, Model): #: 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( - sa.orm.mapped_column(sa.Uuid, nullable=True, index=True), + 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 ) @@ -887,7 +888,7 @@ class UserNotification(UserNotificationMixin, NoIdMixin, Model): [eventid, notification_id], [Notification.eventid, Notification.id], ondelete='CASCADE', - name='user_notification_eventid_notification_id_fkey', + name='notification_recipient_eventid_notification_id_fkey', ), ) @@ -920,9 +921,9 @@ class UserNotification(UserNotificationMixin, NoIdMixin, Model): # --- 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: @@ -986,15 +987,15 @@ def _is_revoked_expression(cls) -> sa.ColumnElement[bool]: # --- 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: @@ -1011,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. @@ -1031,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 @@ -1063,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.is_not(None), + NotificationRecipient.rollupid.is_not(None), ) - .order_by(UserNotification.created_at.asc()) + .order_by(NotificationRecipient.created_at.asc()) .limit(1) .scalar() ) @@ -1101,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 @@ -1142,66 +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[UserNotification]: + 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: Union[Notification, PreviewNotification] + notification: Notification | PreviewNotification identity: Any = None read_at: Any = None revoked_at: Any = None @@ -1211,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 @@ -1246,18 +1248,17 @@ 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( - 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'}, ) @@ -1284,7 +1285,7 @@ class NotificationPreferences(BaseMixin, Model): 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': { @@ -1298,14 +1299,14 @@ class NotificationPreferences(BaseMixin, Model): 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')' ) @@ -1319,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 @@ -1344,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() ), ) @@ -1359,7 +1360,7 @@ def set_transport(self, transport: str, value: bool) -> None: setattr(self, 'by_' + transport, value) @cached_property - def type_cls(self) -> Optional[Type[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 @@ -1367,19 +1368,17 @@ def type_cls(self) -> Optional[Type[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: @@ -1387,29 +1386,29 @@ def _valid_notification_type(self, key: str, value: Optional[str]) -> str: return value -@reopen(User) -class __User: - all_notifications: DynamicMapped[UserNotification] = with_roles( +@reopen(Account) +class __Account: + all_notifications: DynamicMapped[NotificationRecipient] = with_roles( relationship( - UserNotification, + NotificationRecipient, lazy='dynamic', - order_by=UserNotification.created_at.desc(), + order_by=NotificationRecipient.created_at.desc(), viewonly=True, ), read={'owner'}, ) - notification_preferences: Mapped[Dict[str, NotificationPreferences]] = 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: Mapped[NotificationPreferences] = relationship( NotificationPreferences, primaryjoin=sa.and_( - NotificationPreferences.user_id == User.id, + NotificationPreferences.account_id == Account.id, NotificationPreferences.notification_type == '', ), uselist=False, @@ -1422,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, @@ -1441,7 +1440,7 @@ def main_notification_preferences(self) -> NotificationPreferences: @event.listens_for(Notification, 'mapper_configured', propagate=True) -def _register_notification_types(mapper_: Any, cls: Type[Notification]) -> 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: # Add the subclass to the registry diff --git a/funnel/models/notification_types.py b/funnel/models/notification_types.py index abb32fb09..a8fc70102 100644 --- a/funnel/models/notification_types.py +++ b/funnel/models/notification_types.py @@ -6,18 +6,17 @@ from baseframe import __ +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', @@ -43,24 +42,35 @@ class DocumentHasProject: """Mixin class for documents linked to a project.""" @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 # type: ignore[attr-defined] + return self.document.project.account # type: ignore[attr-defined] -class DocumentHasProfile: - """Mixin class for documents linked to an account (nee profile).""" +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 # type: ignore[attr-defined] + 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[User, None], 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 @@ -139,7 +149,7 @@ class ProposalSubmittedNotification( class ProjectStartingNotification( - DocumentHasProfile, + DocumentHasAccount, Notification[Project, Optional[Session]], type='project_starting', ): @@ -182,8 +192,8 @@ class CommentReplyNotification(Notification[Comment, Comment], type='comment_rep class ProjectCrewMembershipNotification( - DocumentHasProfile, - Notification[Project, ProjectCrewMembership], + DocumentHasAccount, + Notification[Project, ProjectMembership], type='project_crew_membership_granted', ): """Notification of being granted crew membership (including role changes).""" @@ -192,24 +202,24 @@ class ProjectCrewMembershipNotification( title = __("When a project crew member is added or removed") description = __("Crew members have access to the project’s settings and data") - 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[Project, ProjectCrewMembership], + DocumentHasAccount, + Notification[Project, ProjectMembership], type='project_crew_membership_revoked', shadows=ProjectCrewMembershipNotification, ): """Notification of being removed from crew membership (including role changes).""" - 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[Project, Proposal], type='proposal_received' + DocumentHasAccount, Notification[Project, Proposal], type='proposal_received' ): """Notification to editors of new proposals.""" @@ -221,7 +231,7 @@ class ProposalReceivedNotification( class RegistrationReceivedNotification( - DocumentHasProfile, Notification[Project, Rsvp], type='rsvp_received' + DocumentHasAccount, Notification[Project, Rsvp], type='rsvp_received' ): """Notification to promoters of new registrations.""" @@ -238,8 +248,8 @@ class RegistrationReceivedNotification( class OrganizationAdminMembershipNotification( - DocumentHasProfile, - Notification[Organization, OrganizationMembership], + DocumentHasAccount, + Notification[Account, AccountMembership], type='organization_membership_granted', ): """Notification of being granted admin membership (including role changes).""" @@ -248,19 +258,19 @@ class OrganizationAdminMembershipNotification( title = __("When account admins change") description = __("Account admins control all projects under the account") - 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[Organization, OrganizationMembership], + DocumentHasAccount, + Notification[Account, AccountMembership], type='organization_membership_revoked', shadows=OrganizationAdminMembershipNotification, ): """Notification of being granted admin membership (including role changes).""" - roles = ['subject', 'profile_admin'] + roles = ['member', 'account_admin'] exclude_actor = True # Alerts other users of actor's actions; too noisy for actor diff --git a/funnel/models/organization_membership.py b/funnel/models/organization_membership.py deleted file mode 100644 index 17778a9fd..000000000 --- a/funnel/models/organization_membership.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Membership model for admins of an organization.""" - -from __future__ import annotations - -from typing import Set - -from werkzeug.utils import cached_property - -from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles - -from . import DynamicMapped, Mapped, Model, relationship, sa -from .helpers import reopen -from .membership_mixin import ImmutableUserMembershipMixin -from .user import Organization, User - -__all__ = ['OrganizationMembership'] - - -class OrganizationMembership(ImmutableUserMembershipMixin, Model): - """ - 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( - 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: DynamicMapped[OrganizationMembership] = with_roles( - relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.organization_id) - == Organization.id, - OrganizationMembership.is_active, - ), - order_by=OrganizationMembership.granted_at.asc(), - viewonly=True, - ), - grants_via={'user': {'admin', 'owner'}}, - ) - - active_owner_memberships: DynamicMapped[OrganizationMembership] = relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.organization_id) == Organization.id, - OrganizationMembership.is_active, - OrganizationMembership.is_owner.is_(True), - ), - viewonly=True, - ) - - active_invitations: DynamicMapped[OrganizationMembership] = relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.organization_id) == Organization.id, - OrganizationMembership.is_invite, - 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: DynamicMapped[ - OrganizationMembership - ] = relationship( - OrganizationMembership, - lazy='dynamic', - foreign_keys=[OrganizationMembership.user_id], # type: ignore[has-type] - viewonly=True, - ) - - noninvite_organization_admin_memberships: DynamicMapped[ - OrganizationMembership - ] = relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.user_id) # type: ignore[has-type] - == User.id, - ~OrganizationMembership.is_invite, - ), - viewonly=True, - ) - - active_organization_admin_memberships: DynamicMapped[ - OrganizationMembership - ] = relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.user_id) # type: ignore[has-type] - == User.id, - OrganizationMembership.is_active, - ), - viewonly=True, - ) - - active_organization_owner_memberships: DynamicMapped[ - OrganizationMembership - ] = relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.user_id) # type: ignore[has-type] - == User.id, - OrganizationMembership.is_active, - OrganizationMembership.is_owner.is_(True), - ), - viewonly=True, - ) - - active_organization_invitations: DynamicMapped[ - OrganizationMembership - ] = relationship( - OrganizationMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(OrganizationMembership.user_id) # type: ignore[has-type] - == User.id, - OrganizationMembership.is_invite, - 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 7201b5fbf..f896267d0 100644 --- a/funnel/models/phone_number.py +++ b/funnel/models/phone_number.py @@ -3,8 +3,7 @@ from __future__ import annotations import hashlib -from typing import TYPE_CHECKING, Any, Optional, Set, Type, Union, overload -from typing_extensions import Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload import base58 import phonenumbers @@ -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. @@ -161,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( @@ -188,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. @@ -208,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: @@ -219,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: @@ -239,7 +237,7 @@ 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. @@ -252,14 +250,13 @@ class PhoneNumber(BaseMixin, Model): """ __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 @@ -397,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) @@ -435,7 +429,7 @@ def is_exclusive(self) -> bool: for related_obj in getattr(self, backref_name) ) - def is_available_for(self, owner: Optional[User]) -> bool: + 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): @@ -502,7 +496,7 @@ def mark_unblocked(self, phone: str) -> None: @overload @classmethod def get_filter( - cls, *, phone: Union[str, phonenumbers.PhoneNumber] + cls, *, phone: str | phonenumbers.PhoneNumber ) -> ColumnElement[bool]: ... @@ -521,9 +515,9 @@ def get_filter(cls, *, phone_hash: str) -> ColumnElement[bool]: def get_filter( cls, *, - phone: Optional[Union[str, phonenumbers.PhoneNumber]], - blake2b160: Optional[bytes], - phone_hash: Optional[str], + phone: str | phonenumbers.PhoneNumber | None, + blake2b160: bytes | None, + phone_hash: str | None, ) -> ColumnElement[bool]: ... @@ -531,9 +525,9 @@ def get_filter( def get_filter( cls, *, - phone: Optional[Union[str, phonenumbers.PhoneNumber]] = None, - blake2b160: Optional[bytes] = None, - phone_hash: Optional[str] = None, + 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`. @@ -554,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 @@ -566,8 +560,8 @@ def get( cls, *, blake2b160: bytes, - is_blocked: Optional[bool] = None, - ) -> Optional[PhoneNumber]: + is_blocked: bool | None = None, + ) -> PhoneNumber | None: ... @overload @@ -576,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. @@ -610,7 +604,7 @@ def get( 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. @@ -637,8 +631,8 @@ def add(cls, phone: Union[str, phonenumbers.PhoneNumber]) -> PhoneNumber: @classmethod def add_for( cls, - owner: Optional[User], - phone: Union[str, phonenumbers.PhoneNumber], + owner: Account | None, + phone: str | phonenumbers.PhoneNumber, ) -> PhoneNumber: """ Create a new :class:`PhoneNumber` after validation. @@ -665,10 +659,10 @@ def add_for( @classmethod def validate_for( cls, - owner: Optional[User], - phone: Union[str, phonenumbers.PhoneNumber], + owner: Account | None, + phone: str | phonenumbers.PhoneNumber, new: bool = False, - ) -> Optional[Literal['taken', 'invalid', 'not_new', 'blocked']]: + ) -> Literal['taken', 'invalid', 'not_new', 'blocked'] | None: """ Validate whether the phone number is available to the proposed owner. @@ -706,7 +700,7 @@ def validate_for( return None @classmethod - def get_numbers(cls, prefix: str, remove: bool = True) -> Set[str]: + 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)) @@ -733,14 +727,14 @@ 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 @@ -765,7 +759,7 @@ def phone_number(cls) -> Mapped[PhoneNumber]: return relationship(PhoneNumber, backref=backref_name) @property - def phone(self) -> Optional[str]: + def phone(self) -> str | None: """ Shorthand for ``self.phone_number.number``. @@ -782,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( @@ -807,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 @@ -887,8 +881,8 @@ def _setup_refcount_events() -> None: def _phone_number_mixin_set_validator( target: PhoneNumberMixin, - value: Optional[PhoneNumber], - old_value: Optional[PhoneNumber], + value: PhoneNumber | None, + old_value: PhoneNumber | None, _initiator: Any, ) -> None: if value is not None and value != old_value and target.__phone_for__: @@ -900,11 +894,11 @@ def _phone_number_mixin_set_validator( @event.listens_for(PhoneNumberMixin, 'mapper_configured', propagate=True) def _phone_number_mixin_configure_events( - _mapper: Any, cls: Type[PhoneNumberMixin] + _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 .user import User + from .account import Account diff --git a/funnel/models/profile.py b/funnel/models/profile.py deleted file mode 100644 index 0864b10d2..000000000 --- a/funnel/models/profile.py +++ /dev/null @@ -1,582 +0,0 @@ -"""Account (nee Profile) model, linked to a User or Organization model.""" - -from __future__ import annotations - -from typing import Any, Iterable, List, Optional, Sequence, Union - -from furl import furl -from sqlalchemy.sql import expression - -from baseframe import __ -from coaster.sqlalchemy import LazyRoleSet, StateManager, immutable, with_roles -from coaster.utils import LabeledEnum - -from ..typing import OptionalMigratedTables -from . import ( - BaseMixin, - Mapped, - MarkdownCompositeDocument, - Model, - Query, - TSVectorType, - UrlType, - UuidMixin, - db, - hybrid_property, - relationship, - 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, Model): - """ - 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.orm.mapped_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.orm.mapped_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( - 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.orm.mapped_column( - sa.Integer, - sa.ForeignKey('organization.id', ondelete='SET NULL'), - unique=True, - nullable=True, - ) - organization: Mapped[Optional[Organization]] = relationship( - 'Organization', - backref=sa.orm.backref('profile', uselist=False, cascade='all'), - ) - #: Reserved account (not assigned to any party) - reserved = sa.orm.mapped_column( - sa.Boolean, nullable=False, default=False, index=True - ) - - _state = sa.orm.mapped_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.orm.mapped_column(sa.Unicode, nullable=True) - description, description_text, description_html = MarkdownCompositeDocument.create( - 'description', default='', nullable=False - ) - website: Mapped[Optional[furl]] = sa.orm.mapped_column(UrlType, nullable=True) - logo_url: Mapped[Optional[ImgeeFurl]] = sa.orm.mapped_column( - ImgeeType, nullable=True - ) - banner_image_url: Mapped[Optional[ImgeeFurl]] = sa.orm.mapped_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.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 = with_roles( - immutable( - sa.orm.mapped_column(sa.Boolean, default=False, nullable=False, index=True) - ), - read={'all'}, - ) - - #: Revision number maintained by SQLAlchemy, starting at 1 - revisionid = with_roles( - sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} - ) - - search_vector: Mapped[TSVectorType] = sa.orm.mapped_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, - deferred=True, - ) - - is_active = with_roles( - sa.orm.column_property( - sa.case( - ( - user_id.is_not(None), # ← when, ↙ then - sa.select(User.state.ACTIVE) - .where(User.id == user_id) - .correlate_except(User) - .scalar_subquery(), - ), - ( - organization_id.is_not(None), # ← when, ↙ then - sa.select(Organization.state.ACTIVE) - .where(Organization.id == organization_id) - .correlate_except(Organization) - .scalar_subquery(), - ), - else_=expression.false(), - ) - ), - read={'all'}, - datasets={'primary', 'related'}, - ) - - __table_args__ = ( - sa.CheckConstraint( - sa.case((user_id.is_not(None), 1), else_=0) - + sa.case((organization_id.is_not(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', - 'tagline', - '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', - 'tagline', - 'description', - 'logo_url', - 'website', - 'user', - 'organization', - 'owner', - 'is_verified', - }, - 'related': { - 'urls', - 'uuid_b58', - 'name', - 'title', - 'tagline', - 'description', - 'logo_url', - 'is_verified', - }, - } - - state.add_conditional_state( - 'ACTIVE_AND_PUBLIC', state.PUBLIC, lambda profile: profile.is_active - ) - - state.add_conditional_state( - 'PUBLISHABLE', - state.NOT_PUBLIC, - lambda profile: ( - profile.reserved is False - and profile.is_active - and (profile.user is None or profile.user.features.not_likely_throwaway) - ), - ) - - 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.inplace.expression - @classmethod - def _is_user_profile_expression(cls) -> sa.ColumnElement[bool]: - """Test if this is a user account in a SQL expression.""" - return cls.user_id.is_not(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.inplace.expression - @classmethod - def _is_organization_profile_expression(cls) -> sa.ColumnElement[bool]: - """Test if this is an organization account in a SQL expression.""" - return cls.organization_id.is_not(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.inplace.setter - def _title_setter(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.inplace.expression - @classmethod - def _title_expression(cls) -> sa.Case: - """Retrieve title as a SQL expression.""" - return sa.case( - ( - # if... - cls.user_id.is_not(None), - # then... - sa.select(User.fullname) - .where(cls.user_id == User.id) - .scalar_subquery(), - ), - ( - # elif... - cls.organization_id.is_not(None), - # then... - sa.select(Organization.title) - .where(cls.organization_id == Organization.id) - .scalar_subquery(), - ), - 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: Sequence = () - ) -> 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) -> sa.ColumnElement[bool]: - """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]) -> sa.ColumnElement[bool]: - """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) -> sa.ColumnElement[bool]: - """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[Profile]: - """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.PUBLISHABLE, - state.PUBLIC, - title=__("Make public"), - ) - 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 5aa8b7c0f..5bf0787bc 100644 --- a/funnel/models/project.py +++ b/funnel/models/project.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Optional, Sequence +from collections.abc import Sequence from pytz import utc from sqlalchemy.orm import attribute_keyed_dict @@ -13,12 +13,10 @@ from coaster.utils import LabeledEnum, buid, utcnow from .. import app -from ..typing import OptionalMigratedTables from . import ( BaseScopedNameMixin, DynamicMapped, Mapped, - MarkdownCompositeDocument, Model, Query, TimestampMixin, @@ -26,22 +24,23 @@ TSVectorType, UrlType, UuidMixin, + backref, db, 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'] @@ -70,35 +69,33 @@ class CFP_STATE(LabeledEnum): # noqa: N801 class Project(UuidMixin, BaseScopedNameMixin, Model): __tablename__ = 'project' - __allow_unmapped__ = True reserved_names = RESERVED_NAMES - user_id = sa.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = relationship( - User, - foreign_keys=[user_id], - backref=sa.orm.backref('projects', cascade='all'), - ) - profile_id = sa.orm.mapped_column( - sa.Integer, sa.ForeignKey('profile.id'), nullable=False + created_by_id = sa.orm.mapped_column(sa.ForeignKey('account.id'), nullable=False) + created_by: Mapped[Account] = relationship( + Account, + foreign_keys=[created_by_id], ) - profile: Mapped[Profile] = with_roles( + account_id = sa.orm.mapped_column(sa.ForeignKey('account.id'), nullable=False) + account: Mapped[Account] = with_roles( relationship( - Profile, backref=sa.orm.backref('projects', cascade='all', lazy='dynamic') + 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 + # If account grants an 'admin' role, make it 'account_admin' here grants_via={ None: { - 'admin': 'profile_admin', + 'admin': 'account_admin', 'follower': 'account_participant', } }, - # `profile` only appears in the 'primary' dataset. It must not be included in + # `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.orm.mapped_column(sa.Unicode(250), nullable=False), read={'all'}, @@ -199,7 +196,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) - buy_tickets_url: Mapped[Optional[str]] = with_roles( + buy_tickets_url: Mapped[str | None] = with_roles( sa.orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, @@ -240,7 +237,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): parent_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='SET NULL'), nullable=True ) - parent_project: Mapped[Optional[Project]] = relationship( + parent_project: Mapped[Project | None] = relationship( 'Project', remote_side='Project.id', backref='subprojects' ) @@ -253,6 +250,21 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): datasets={'primary', 'without_parent'}, ) + 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'}, + ) + + 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'} @@ -285,17 +297,11 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): deferred=True, ) - 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'}, - ) + # 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_( @@ -456,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, ) @@ -467,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( @@ -539,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. @@ -563,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] @@ -632,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)): @@ -687,7 +701,7 @@ def update_schedule_timestamps(self): self.end_at = self.schedule_end_at def roles_for( - self, actor: Optional[User] = None, anchors: Sequence = () + self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: roles = super().roles_for(actor, anchors) # https://github.com/hasgeek/funnel/pull/220#discussion_r168718052 @@ -715,9 +729,9 @@ def order_by_date(cls) -> sa.Case: 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 @@ -729,23 +743,21 @@ def all(cls) -> Query[Project]: # noqa: A003 # convenience as this is only used in shell access. @classmethod def get( # type: ignore[override] # pylint: disable=arguments-differ - cls, profile_project: str - ) -> Optional[Project]: - """Get a project by its URL slug in the form ``/``.""" - profile_name, project_name = profile_project.split('/') + 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," @@ -753,70 +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: DynamicMapped[Project] = with_roles( - relationship( - Project, - lazy='dynamic', - primaryjoin=sa.and_( - Profile.id == Project.profile_id, - Project.state.PUBLISHED, - ), - viewonly=True, + listed_projects: DynamicMapped[Project] = relationship( + Project, + lazy='dynamic', + primaryjoin=sa.and_( + Account.id == Project.account_id, + Project.state.PUBLISHED, ), - # This grant of follower from Project participant is interim until Account gets - # it's own follower membership model - grants_via={None: {'participant': {'follower'}}}, + viewonly=True, ) 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( relationship( - Project, collection_class=attribute_keyed_dict('name'), viewonly=True + 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), ) @@ -833,16 +843,17 @@ def published_project_count(self) -> int: class ProjectRedirect(TimestampMixin, Model): __tablename__ = 'project_redirect' - __allow_unmapped__ = True - profile_id = sa.orm.mapped_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 + ) + account: Mapped[Account] = relationship( + Account, backref=backref('project_redirects', cascade='all') ) - profile: Mapped[Profile] = relationship( - Profile, backref=sa.orm.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.orm.mapped_column(sa.Unicode(250), nullable=False, primary_key=True) project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='SET NULL'), nullable=True @@ -852,61 +863,60 @@ class ProjectRedirect(TimestampMixin, Model): 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: Project, - profile: Optional[Profile] = None, - name: Optional[str] = None, + 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 = cls.query.get((profile.id, name)) + redirect = cls.query.get((account.id, name)) if redirect is None: - redirect = cls(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 @@ -915,13 +925,12 @@ def migrate_profile( # type: ignore[return] class ProjectLocation(TimestampMixin, Model): __tablename__ = 'project_location' - __allow_unmapped__ = True #: Project we are tagging project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), primary_key=True, nullable=False ) project: Mapped[Project] = relationship( - Project, backref=sa.orm.backref('locations', cascade='all') + Project, backref=backref('locations', cascade='all') ) #: Geonameid for this project geonameid = sa.orm.mapped_column( @@ -947,5 +956,5 @@ class __Commentset: # 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 6c97d3207..7b50b15c5 100644 --- a/funnel/models/project_membership.py +++ b/funnel/models/project_membership.py @@ -2,41 +2,40 @@ from __future__ import annotations -from typing import Set - from werkzeug.utils import cached_property from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles -from . import DynamicMapped, Mapped, Model, declared_attr, relationship, 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'] #: Roles in a project and their remapped names in objects attached to a project -project_child_role_map = { - 'editor': {'project_editor'}, - 'promoter': {'project_promoter'}, - 'usher': {'project_usher'}, - 'crew': {'project_crew'}, - 'participant': {'project_participant'}, - 'reader': {'reader'}, +project_child_role_map: dict[str, str] = { + 'editor': 'project_editor', + 'promoter': 'project_promoter', + 'usher': 'project_usher', + 'crew': 'project_crew', + 'participant': 'project_participant', + 'reader': 'reader', } -#: ProjectCrewMembership maps project's `profile_admin` role to membership's `editor` +#: ProjectMembership maps project's `account_admin` role to membership's `editor` #: role in addition to the recurring role grant map -project_membership_role_map = {'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, Model): +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 @@ -48,7 +47,7 @@ class ProjectCrewMembership(ImmutableUserMembershipMixin, Model): 'all': { 'read': { 'urls', - 'user', + 'member', 'project', 'is_editor', 'is_promoter', @@ -77,7 +76,7 @@ class ProjectCrewMembership(ImmutableUserMembershipMixin, Model): 'is_promoter', 'is_usher', 'label', - 'user', + 'member', 'project', }, 'without_parent': { @@ -88,7 +87,7 @@ class ProjectCrewMembership(ImmutableUserMembershipMixin, Model): 'is_promoter', 'is_usher', 'label', - 'user', + 'member', }, 'related': { 'urls', @@ -107,7 +106,7 @@ class ProjectCrewMembership(ImmutableUserMembershipMixin, Model): project: Mapped[Project] = with_roles( relationship( Project, - backref=sa.orm.backref( + backref=backref( 'crew_memberships', lazy='dynamic', cascade='all', @@ -170,7 +169,7 @@ def __table_args__(cls) -> tuple: 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,100 +184,92 @@ def offered_roles(self) -> Set[str]: # Project relationships: all crew, vs specific roles @reopen(Project) class __Project: - active_crew_memberships: DynamicMapped[ProjectCrewMembership] = with_roles( + active_crew_memberships: DynamicMapped[ProjectMembership] = with_roles( relationship( - ProjectCrewMembership, + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.project_id == Project.id, - ProjectCrewMembership.is_active, + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, ), viewonly=True, ), - grants_via={ - 'user': { - 'editor': {'editor', 'project_editor'}, - 'promoter': {'promoter', 'project_promoter'}, - 'usher': {'usher', 'project_usher'}, - 'participant': {'participant', 'project_participant'}, - 'crew': {'crew', 'project_crew'}, - } - }, + grants_via={'member': {'editor', 'promoter', 'usher', 'participant', 'crew'}}, ) - active_editor_memberships: DynamicMapped[ProjectCrewMembership] = relationship( - ProjectCrewMembership, + active_editor_memberships: DynamicMapped[ProjectMembership] = relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.project_id == Project.id, - ProjectCrewMembership.is_active, - ProjectCrewMembership.is_editor.is_(True), + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, + ProjectMembership.is_editor.is_(True), ), viewonly=True, ) - active_promoter_memberships: DynamicMapped[ProjectCrewMembership] = relationship( - ProjectCrewMembership, + active_promoter_memberships: DynamicMapped[ProjectMembership] = relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.project_id == Project.id, - ProjectCrewMembership.is_active, - ProjectCrewMembership.is_promoter.is_(True), + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, + ProjectMembership.is_promoter.is_(True), ), viewonly=True, ) - active_usher_memberships: DynamicMapped[ProjectCrewMembership] = relationship( - ProjectCrewMembership, + active_usher_memberships: DynamicMapped[ProjectMembership] = relationship( + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.project_id == Project.id, - ProjectCrewMembership.is_active, - 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: DynamicMapped[ProjectCrewMembership] = 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: DynamicMapped[ - ProjectCrewMembership + ProjectMembership ] = relationship( - ProjectCrewMembership, + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.user_id == User.id, - ~ProjectCrewMembership.is_invite, + ProjectMembership.member_id == Account.id, + ~ProjectMembership.is_invite, ), viewonly=True, ) projects_as_crew_active_memberships: DynamicMapped[ - ProjectCrewMembership + ProjectMembership ] = relationship( - ProjectCrewMembership, + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.user_id == User.id, - ProjectCrewMembership.is_active, + ProjectMembership.member_id == Account.id, + ProjectMembership.is_active, ), viewonly=True, ) @@ -288,14 +279,14 @@ class __User: ) projects_as_editor_active_memberships: DynamicMapped[ - ProjectCrewMembership + ProjectMembership ] = relationship( - ProjectCrewMembership, + ProjectMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectCrewMembership.user_id == User.id, - ProjectCrewMembership.is_active, - ProjectCrewMembership.is_editor.is_(True), + ProjectMembership.member_id == Account.id, + ProjectMembership.is_active, + ProjectMembership.is_editor.is_(True), ), viewonly=True, ) @@ -305,5 +296,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 7f5072d2f..3f504f63f 100644 --- a/funnel/models/proposal.py +++ b/funnel/models/proposal.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime as datetime_type -from typing import Optional, Sequence from baseframe import __ from baseframe.filters import preview @@ -14,21 +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'] @@ -117,14 +122,13 @@ class Proposal( # type: ignore[misc] UuidMixin, BaseScopedIdNameMixin, VideoMixin, ReorderMixin, Model ): __tablename__ = 'proposal' - __allow_unmapped__ = True - user_id = sa.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user = with_roles( + created_by_id = sa.orm.mapped_column(sa.ForeignKey('account.id'), nullable=False) + created_by = with_roles( relationship( - User, - foreign_keys=[user_id], - backref=sa.orm.backref('created_proposals', cascade='all', lazy='dynamic'), + Account, + foreign_keys=[created_by_id], + backref=backref('created_proposals', cascade='all', lazy='dynamic'), ), grants={'creator', 'participant'}, ) @@ -135,7 +139,7 @@ class Proposal( # type: ignore[misc] relationship( Project, foreign_keys=[project_id], - backref=sa.orm.backref( + backref=backref( 'proposals', cascade='all', lazy='dynamic', order_by='Proposal.url_id' ), ), @@ -227,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', @@ -241,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', + }, }, } @@ -252,7 +261,7 @@ class Proposal( # type: ignore[misc] 'url_name_uuid_b58', 'title', 'body', - 'user', + 'created_by', 'first_user', 'session', 'project', @@ -263,7 +272,7 @@ class Proposal( # type: ignore[misc] 'url_name_uuid_b58', 'title', 'body', - 'user', + 'created_by', 'first_user', 'session', }, @@ -273,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', @@ -438,7 +457,7 @@ def update_description(self) -> None: if not self.custom_description: self.description = preview(self.body_html) - def getnext(self) -> Optional[Proposal]: + def getnext(self) -> Proposal | None: return ( Proposal.query.filter( Proposal.project == self.project, @@ -448,7 +467,7 @@ def getnext(self) -> Optional[Proposal]: .first() ) - def getprev(self) -> Optional[Proposal]: + def getprev(self) -> Proposal | None: return ( Proposal.query.filter( Proposal.project == self.project, @@ -459,7 +478,7 @@ def getprev(self) -> Optional[Proposal]: ) def roles_for( - self, actor: Optional[User] = None, anchors: Sequence = () + self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: roles = super().roles_for(actor, anchors) if self.state.DRAFT: @@ -481,7 +500,7 @@ def all_public(cls) -> Query[Proposal]: @classmethod def get( # type: ignore[override] # pylint: disable=arguments-differ cls, uuid_b58: str - ) -> Optional[Proposal]: + ) -> Proposal | None: """Get a proposal by its public Base58 id.""" return cls.query.filter_by(uuid_b58=uuid_b58).one_or_none() @@ -493,7 +512,6 @@ class ProposalSuuidRedirect(BaseMixin, Model): """Holds Proposal SUUIDs from before when they were deprecated.""" __tablename__ = 'proposal_suuid_redirect' - __allow_unmapped__ = True suuid = sa.orm.mapped_column(sa.Unicode(22), nullable=False, index=True) proposal_id = sa.orm.mapped_column( @@ -556,7 +574,7 @@ 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: Mapped[Optional[bool]] = 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)) diff --git a/funnel/models/proposal_membership.py b/funnel/models/proposal_membership.py index a6057ae35..1813a6138 100644 --- a/funnel/models/proposal_membership.py +++ b/funnel/models/proposal_membership.py @@ -2,13 +2,12 @@ from __future__ import annotations -from typing import Set - from werkzeug.utils import cached_property from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles -from . import DynamicMapped, Mapped, Model, relationship, sa +from . import DynamicMapped, Mapped, Model, backref, relationship, sa +from .account import Account from .helpers import reopen from .membership_mixin import ( FrozenAttributionMixin, @@ -17,7 +16,6 @@ ) from .project import Project from .proposal import Proposal -from .user import User __all__ = ['ProposalMembership'] @@ -28,14 +26,13 @@ class ProposalMembership( # type: ignore[misc] """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': { @@ -51,7 +48,7 @@ class ProposalMembership( # type: ignore[misc] 'seq', 'title', 'urls', - 'user', + 'member', 'uuid_b58', }, 'without_parent': { @@ -61,7 +58,7 @@ class ProposalMembership( # type: ignore[misc] 'seq', 'title', 'urls', - 'user', + 'member', 'uuid_b58', }, 'related': { @@ -75,7 +72,7 @@ class ProposalMembership( # type: ignore[misc] }, } - revoke_on_subject_delete = False + revoke_on_member_delete = False proposal_id: Mapped[int] = with_roles( sa.orm.mapped_column( @@ -83,20 +80,20 @@ class ProposalMembership( # type: ignore[misc] sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False, ), - read={'subject', 'editor'}, + read={'member', 'editor'}, ) proposal: Mapped[Proposal] = with_roles( 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') @@ -118,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'} @@ -127,7 +124,7 @@ 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 @@ -143,26 +140,26 @@ class __Proposal: ), 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: DynamicMapped[ProposalMembership] = relationship( ProposalMembership, lazy='dynamic', - foreign_keys=[ProposalMembership.user_id], + foreign_keys=[ProposalMembership.member_id], viewonly=True, ) @@ -170,7 +167,7 @@ class __User: ProposalMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalMembership.user_id == User.id, + ProposalMembership.member_id == Account.id, ~ProposalMembership.is_invite, ), viewonly=True, @@ -180,7 +177,7 @@ class __User: ProposalMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalMembership.user_id == User.id, + ProposalMembership.member_id == Account.id, ProposalMembership.is_active, ), viewonly=True, @@ -205,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 03257660b..b8367035e 100644 --- a/funnel/models/reorder_mixin.py +++ b/funnel/models/reorder_mixin.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, ClassVar, TypeVar, Union +from typing import TYPE_CHECKING, ClassVar, TypeVar from uuid import UUID from . import Mapped, QueryProperty, db, declarative_mixin, sa @@ -26,7 +26,7 @@ class ReorderMixin: #: 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[Union[int, UUID]] + 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 diff --git a/funnel/models/rsvp.py b/funnel/models/rsvp.py index 6f598e58a..a5bd82361 100644 --- a/funnel/models/rsvp.py +++ b/funnel/models/rsvp.py @@ -2,8 +2,7 @@ from __future__ import annotations -from typing import Dict, Optional, Tuple, Union, cast, overload -from typing_extensions import Literal +from typing import Literal, cast, overload from flask import current_app from werkzeug.utils import cached_property @@ -12,12 +11,22 @@ from coaster.sqlalchemy import StateManager, with_roles from coaster.utils import LabeledEnum -from ..typing import OptionalMigratedTables -from . import Mapped, Model, NoIdMixin, UuidMixin, db, relationship, sa, types +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'] @@ -33,30 +42,25 @@ class RSVP_STATUS(LabeledEnum): # noqa: N801 class Rsvp(UuidMixin, NoIdMixin, Model): __tablename__ = 'rsvp' - __allow_unmapped__ = True project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False, primary_key=True ) project = with_roles( - relationship( - Project, backref=sa.orm.backref('rsvps', cascade='all', lazy='dynamic') - ), + 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.orm.mapped_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( - relationship( - User, backref=sa.orm.backref('rsvps', cascade='all', lazy='dynamic') - ), + participant = with_roles( + relationship(Account, backref=backref('rsvps', cascade='all', lazy='dynamic')), read={'owner', 'project_promoter'}, grants={'owner'}, datasets={'primary', 'without_parent'}, ) - form: Mapped[Optional[types.jsonb]] = with_roles( + form: Mapped[types.jsonb | None] = with_roles( sa.orm.mapped_column(), rw={'owner'}, read={'project_promoter'}, @@ -125,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 @@ -202,41 +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', 'project_participant'}} + 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() ) @@ -245,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 581fd8044..4310c14ad 100644 --- a/funnel/models/saved.py +++ b/funnel/models/saved.py @@ -2,31 +2,29 @@ from __future__ import annotations -from typing import Optional, Sequence +from collections.abc import Sequence from coaster.sqlalchemy import LazyRoleSet, with_roles -from ..typing import OptionalMigratedTables -from . import Mapped, Model, NoIdMixin, db, relationship, 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, Model): __tablename__ = 'saved_project' - #: User who saved this project - user_id = sa.orm.mapped_column( - sa.Integer, - sa.ForeignKey('user.id', ondelete='CASCADE'), + #: 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] = 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.orm.mapped_column( @@ -38,7 +36,7 @@ class SavedProject(NoIdMixin, Model): ) 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.orm.mapped_column( @@ -48,22 +46,20 @@ class SavedProject(NoIdMixin, Model): description = sa.orm.mapped_column(sa.UnicodeText, nullable=True) def roles_for( - self, actor: Optional[User] = None, anchors: Sequence = () + 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) @@ -71,16 +67,15 @@ def migrate_user( # type: ignore[return] class SavedSession(NoIdMixin, Model): __tablename__ = 'saved_session' - #: User who saved this session - user_id = sa.orm.mapped_column( - sa.Integer, - sa.ForeignKey('user.id', ondelete='CASCADE'), + #: 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] = 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.orm.mapped_column( @@ -92,7 +87,7 @@ class SavedSession(NoIdMixin, Model): ) 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.orm.mapped_column( @@ -102,30 +97,28 @@ class SavedSession(NoIdMixin, Model): description = sa.orm.mapped_column(sa.UnicodeText, nullable=True) def roles_for( - self, actor: Optional[User] = None, anchors: Sequence = () + 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) @@ -133,7 +126,7 @@ def saved_sessions_in(self, project): @reopen(Project) class __Project: @with_roles(call={'all'}) - def is_saved_by(self, user) -> bool: + 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 5a3f22816..f6b34289c 100644 --- a/funnel/models/session.py +++ b/funnel/models/session.py @@ -4,7 +4,7 @@ from collections import OrderedDict, defaultdict from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Type +from typing import Any from flask_babel import format_date, get_locale from isoweek import Week @@ -18,21 +18,27 @@ 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 @@ -41,14 +47,13 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): __tablename__ = 'session' - __allow_unmapped__ = True project_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( relationship( - Project, backref=sa.orm.backref('sessions', cascade='all', lazy='dynamic') + Project, backref=backref('sessions', cascade='all', lazy='dynamic') ), grants_via={None: project_child_role_map}, ) @@ -59,8 +64,8 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): proposal_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id'), nullable=True, unique=True ) - proposal: Mapped[Optional[Proposal]] = 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( @@ -72,12 +77,13 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): venue_room_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('venue_room.id'), nullable=True ) - venue_room: Mapped[Optional[VenueRoom]] = 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) - banner_image_url: Mapped[Optional[str]] = sa.orm.mapped_column( + 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 ) @@ -142,6 +148,7 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -164,6 +171,7 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -179,6 +187,7 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -194,6 +203,7 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): 'end_at', 'venue_room', 'is_break', + 'is_restricted_video', 'banner_image_url', 'start_at_localized', 'end_at_localized', @@ -201,7 +211,7 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): } @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 @@ -218,7 +228,7 @@ def _scheduled_expression(cls) -> sa.ColumnElement[bool]: return (cls.start_at.is_not(None)) & (cls.end_at.is_not(None)) @cached_property - def start_at_localized(self) -> Optional[datetime]: + def start_at_localized(self) -> datetime | None: return ( localize_timezone(self.start_at, tz=self.project.timezone) if self.start_at @@ -226,7 +236,7 @@ def start_at_localized(self) -> Optional[datetime]: ) @cached_property - def end_at_localized(self) -> Optional[datetime]: + def end_at_localized(self) -> datetime | None: return ( localize_timezone(self.end_at, tz=self.project.timezone) if self.end_at @@ -250,9 +260,7 @@ def location(self) -> str: with_roles(location, read={'all'}) @classmethod - def for_proposal( - cls, proposal: Proposal, create: bool = False - ) -> Optional[Session]: + 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( @@ -280,7 +288,7 @@ def all_public(cls) -> Query[Session]: @reopen(VenueRoom) class __VenueRoom: - scheduled_sessions: Mapped[List[Session]] = relationship( + scheduled_sessions: Mapped[list[Session]] = relationship( Session, primaryjoin=sa.and_( Session.venue_room_id == VenueRoom.id, @@ -294,7 +302,7 @@ 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.is_not(None)) @@ -306,7 +314,7 @@ class __Project: 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( @@ -325,13 +333,14 @@ class __Project: ) .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.is_not(None)) @@ -345,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 @@ -354,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 @@ -363,10 +372,10 @@ def schedule_end_at_localized(self): @with_roles(read={'all'}) @cached_property - def session_count(self): + def session_count(self) -> int: return self.sessions.filter(Session.start_at.is_not(None)).count() - featured_sessions = with_roles( + featured_sessions: Mapped[list[Session]] = with_roles( relationship( Session, order_by=Session.start_at.asc(), @@ -377,7 +386,7 @@ def session_count(self): ), read={'all'}, ) - scheduled_sessions = with_roles( + scheduled_sessions: Mapped[list[Session]] = with_roles( relationship( Session, order_by=Session.start_at.asc(), @@ -389,7 +398,7 @@ def session_count(self): ), read={'all'}, ) - unscheduled_sessions = with_roles( + unscheduled_sessions: Mapped[list[Session]] = with_roles( relationship( Session, order_by=Session.start_at.asc(), @@ -421,7 +430,7 @@ def session_count(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: datetime) -> Optional[Session]: + def next_session_from(self, timestamp: datetime) -> Session | None: """Find the next session in this project from given timestamp.""" return ( self.sessions.filter( @@ -433,8 +442,8 @@ def next_session_from(self, timestamp: datetime) -> Optional[Session]: @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. @@ -463,7 +472,7 @@ 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. @@ -521,7 +530,7 @@ def starting_at( # type: ignore[misc] ) @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 @@ -543,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: @@ -637,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) @@ -693,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 7cdcebb5a..fb04c4795 100644 --- a/funnel/models/shortlink.py +++ b/funnel/models/shortlink.py @@ -5,9 +5,9 @@ import hashlib import re from base64 import urlsafe_b64decode, urlsafe_b64encode +from collections.abc import Iterable from os import urandom -from typing import Any, Iterable, Optional, Union, overload -from typing_extensions import Literal +from typing import Any, Literal, overload from furl import furl from sqlalchemy.exc import IntegrityError @@ -16,8 +16,8 @@ from coaster.sqlalchemy import immutable, with_roles 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'] @@ -48,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: @@ -77,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. @@ -140,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. @@ -176,7 +176,7 @@ def __eq__(self, other: Any) -> sa.ColumnElement[bool]: # type: ignore[override is_ = __eq__ # type: ignore[assignment] def in_( # type: ignore[override] - self, other: Iterable[Union[str, bytes]] + self, other: Iterable[str | bytes] ) -> sa.ColumnElement: """Return an expression for other IN column.""" return self.__clause_element__().in_( # type: ignore[attr-defined] @@ -191,7 +191,6 @@ 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 @@ -211,12 +210,12 @@ class Shortlink(NoIdMixin, Model): immutable(sa.orm.mapped_column(UrlType, nullable=False, index=True)), read={'all'}, ) - #: Id of user who created this shortlink (optional) - user_id = sa.orm.mapped_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]] = 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.orm.mapped_column(sa.Boolean, nullable=False, default=True) @@ -229,7 +228,7 @@ def name(self) -> str: return bigint_to_name(self.id) @name.inplace.setter - def _name_setter(self, value: Union[str, bytes]) -> None: + def _name_setter(self, value: str | bytes) -> None: """Set a name.""" self.id = name_to_bigint(value) @@ -264,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: ... @@ -277,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. @@ -330,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() @@ -346,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): @@ -377,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. diff --git a/funnel/models/site_membership.py b/funnel/models/site_membership.py index 69721b696..2121e13dc 100644 --- a/funnel/models/site_membership.py +++ b/funnel/models/site_membership.py @@ -2,11 +2,10 @@ from __future__ import annotations -from typing import Set - from werkzeug.utils import cached_property -from . import Mapped, Model, User, declared_attr, relationship, sa +from . import Mapped, Model, declared_attr, relationship, sa +from .account import Account from .helpers import reopen from .membership_mixin import ImmutableUserMembershipMixin @@ -17,7 +16,6 @@ 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__ = { @@ -28,10 +26,10 @@ class SiteMembership(ImmutableUserMembershipMixin, Model): } __roles__ = { - 'subject': { + 'member': { 'read': { 'urls', - 'user', + 'member', 'is_comment_moderator', 'is_user_moderator', 'is_site_editor', @@ -86,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. """ @@ -112,14 +110,14 @@ 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: Mapped[SiteMembership] = relationship( SiteMembership, lazy='select', primaryjoin=sa.and_( - SiteMembership.user_id == User.id, # type: ignore[has-type] + SiteMembership.member_id == Account.id, # type: ignore[has-type] SiteMembership.is_active, ), viewonly=True, diff --git a/funnel/models/sponsor_membership.py b/funnel/models/sponsor_membership.py index 58765c2ec..3bfb4ff34 100644 --- a/funnel/models/sponsor_membership.py +++ b/funnel/models/sponsor_membership.py @@ -2,20 +2,18 @@ from __future__ import annotations -from typing import Set - from werkzeug.utils import cached_property from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles -from . import DynamicMapped, Mapped, Model, db, relationship, 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 @@ -25,13 +23,12 @@ class ProjectSponsorMembership( # type: ignore[misc] FrozenAttributionMixin, ReorderMembershipMixin, - ImmutableProfileMembershipMixin, + 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') @@ -41,7 +38,7 @@ class ProjectSponsorMembership( # type: ignore[misc] 'read': { 'is_promoted', 'label', - 'profile', + 'member', 'project', 'seq', 'title', @@ -55,7 +52,7 @@ class ProjectSponsorMembership( # type: ignore[misc] 'is_promoted', 'label', 'offered_roles', - 'profile', + 'member', 'project', 'seq', 'title', @@ -66,7 +63,7 @@ class ProjectSponsorMembership( # type: ignore[misc] 'is_promoted', 'label', 'offered_roles', - 'profile', + 'member', 'seq', 'title', 'urls', @@ -83,14 +80,14 @@ class ProjectSponsorMembership( # type: ignore[misc] }, } - revoke_on_subject_delete = False + revoke_on_member_delete = False project_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False ) project: Mapped[Project] = relationship( Project, - backref=sa.orm.backref( + backref=backref( 'all_sponsor_memberships', lazy='dynamic', cascade='all', @@ -122,7 +119,7 @@ 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() @@ -148,19 +145,20 @@ class __Project: 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, + 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') @@ -170,7 +168,7 @@ class ProposalSponsorMembership( # type: ignore[misc] 'read': { 'is_promoted', 'label', - 'profile', + 'member', 'proposal', 'seq', 'title', @@ -184,7 +182,7 @@ class ProposalSponsorMembership( # type: ignore[misc] 'is_promoted', 'label', 'offered_roles', - 'profile', + 'member', 'proposal', 'seq', 'title', @@ -195,7 +193,7 @@ class ProposalSponsorMembership( # type: ignore[misc] 'is_promoted', 'label', 'offered_roles', - 'profile', + 'member', 'seq', 'title', 'urls', @@ -212,14 +210,14 @@ class ProposalSponsorMembership( # type: ignore[misc] }, } - revoke_on_subject_delete = False + revoke_on_member_delete = False proposal_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False ) proposal: Mapped[Proposal] = relationship( Proposal, - backref=sa.orm.backref( + backref=backref( 'all_sponsor_memberships', lazy='dynamic', cascade='all', @@ -246,7 +244,7 @@ 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() @@ -272,11 +270,11 @@ class __Proposal: 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: DynamicMapped[ ProjectSponsorMembership @@ -284,7 +282,7 @@ class __Profile: ProjectSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectSponsorMembership.profile_id == Profile.id, + ProjectSponsorMembership.member_id == Account.id, ~ProjectSponsorMembership.is_invite, ), order_by=ProjectSponsorMembership.granted_at.desc(), @@ -295,7 +293,7 @@ class __Profile: ProjectSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectSponsorMembership.profile_id == Profile.id, + ProjectSponsorMembership.member_id == Account.id, ProjectSponsorMembership.is_active, ), order_by=ProjectSponsorMembership.granted_at.desc(), @@ -309,7 +307,7 @@ class __Profile: ProjectSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProjectSponsorMembership.profile_id == Profile.id, + ProjectSponsorMembership.member_id == Account.id, ProjectSponsorMembership.is_invite, ProjectSponsorMembership.revoked_at.is_(None), ), @@ -325,7 +323,7 @@ class __Profile: ProposalSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalSponsorMembership.profile_id == Profile.id, + ProposalSponsorMembership.member_id == Account.id, ~ProposalSponsorMembership.is_invite, ), order_by=ProposalSponsorMembership.granted_at.desc(), @@ -338,7 +336,7 @@ class __Profile: ProposalSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalSponsorMembership.profile_id == Profile.id, + ProposalSponsorMembership.member_id == Account.id, ProposalSponsorMembership.is_active, ), order_by=ProposalSponsorMembership.granted_at.desc(), @@ -352,7 +350,7 @@ class __Profile: ProposalSponsorMembership, lazy='dynamic', primaryjoin=sa.and_( - ProposalSponsorMembership.profile_id == Profile.id, + ProposalSponsorMembership.member_id == Account.id, ProposalSponsorMembership.is_invite, ProposalSponsorMembership.revoked_at.is_(None), ), @@ -371,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 bf1ced27c..d51940425 100644 --- a/funnel/models/sync_ticket.py +++ b/funnel/models/sync_ticket.py @@ -4,7 +4,8 @@ import base64 import os -from typing import Any, Iterable, List, Optional, Sequence +from collections.abc import Iterable, Sequence +from typing import Any from coaster.sqlalchemy import LazyRoleSet @@ -14,17 +15,19 @@ 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', @@ -72,8 +75,8 @@ def make_private_key(): class GetTitleMixin(BaseScopedNameMixin): @classmethod def get( - cls, parent: Any, name: Optional[str] = None, title: Optional[str] = None - ) -> Optional[GetTitleMixin]: + 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: @@ -84,8 +87,8 @@ def get( def upsert( # type: ignore[override] # pylint: disable=arguments-renamed cls, parent: Any, - current_name: Optional[str] = None, - current_title: Optional[str] = None, + current_name: str | None = None, + current_title: str | None = None, **fields, ) -> GetTitleMixin: instance = cls.get(parent, current_name, current_title) @@ -110,18 +113,17 @@ class TicketEvent(GetTitleMixin, Model): """ __tablename__ = 'ticket_event' - __allow_unmapped__ = True project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( - 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( + ticket_types: Mapped[list[TicketType]] = with_roles( relationship( 'TicketType', secondary=ticket_event_ticket_type, @@ -166,18 +168,17 @@ class TicketType(GetTitleMixin, Model): """ __tablename__ = 'ticket_type' - __allow_unmapped__ = True project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( - 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( + ticket_events: Mapped[list[TicketEvent]] = with_roles( relationship( TicketEvent, secondary=ticket_event_ticket_type, @@ -206,38 +207,37 @@ 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_for__ = 'participant' fullname = with_roles( sa.orm.mapped_column(sa.Unicode(80), nullable=False), - read={'promoter', 'subject', 'scanner'}, + read={'promoter', 'member', 'scanner'}, ) #: Unvalidated phone number phone = with_roles( sa.orm.mapped_column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + read={'promoter', 'member', 'scanner'}, ) #: Unvalidated Twitter id twitter = with_roles( sa.orm.mapped_column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + read={'promoter', 'member', 'scanner'}, ) #: Job title job_title = with_roles( sa.orm.mapped_column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + read={'promoter', 'member', 'scanner'}, ) #: Company company = with_roles( sa.orm.mapped_column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + read={'promoter', 'member', 'scanner'}, ) #: Participant's city city = with_roles( sa.orm.mapped_column(sa.Unicode(80), nullable=True), - read={'promoter', 'subject', 'scanner'}, + read={'promoter', 'member', 'scanner'}, ) # public key puk = sa.orm.mapped_column( @@ -247,16 +247,18 @@ class TicketParticipant(EmailAddressMixin, UuidMixin, BaseMixin, Model): sa.Unicode(44), nullable=False, default=make_private_key, unique=True ) badge_printed = sa.orm.mapped_column(sa.Boolean, default=False, nullable=False) - user_id = sa.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=True) - user: Mapped[Optional[User]] = relationship( - User, backref=sa.orm.backref('ticket_participants', cascade='all') + 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: Mapped[Project] = with_roles( relationship(Project, back_populates='ticket_participants'), - read={'promoter', 'subject', 'scanner'}, + read={'promoter', 'member', 'scanner'}, grants_via={None: project_child_role_map}, ) @@ -266,17 +268,17 @@ class TicketParticipant(EmailAddressMixin, UuidMixin, BaseMixin, Model): # `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: Sequence = () + 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') @@ -284,30 +286,26 @@ 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) -> bool: - return self.user.has_public_profile if self.user else False + return self.participant.has_public_profile if self.participant else False with_roles(has_public_profile, read={'all'}) @property - def profile_url(self): - return ( - self.user.profile.url_for() - if self.user and self.user.has_public_profile - else None - ) + def profile_url(self) -> str | None: + return self.participant.profile_url if self.participant else None with_roles(profile_url, read={'all'}) @classmethod def get( cls, current_project: Project, current_email: str - ) -> Optional[TicketParticipant]: + ) -> TicketParticipant | None: return cls.query.filter_by( project=current_project, email_address=EmailAddress.get(current_email) ).one_or_none() @@ -317,18 +315,21 @@ 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 @@ -344,7 +345,7 @@ def remove_events(self, ticket_events: Iterable[TicketEvent]) -> None: self.ticket_events.remove(ticket_event) @classmethod - def checkin_list(cls, ticket_event: TicketEvent) -> List: # TODO: List type? + def checkin_list(cls, ticket_event: TicketEvent) -> list: # TODO: List type? """ Return ticket participant details as a comma separated string. @@ -368,7 +369,7 @@ def checkin_list(cls, ticket_event: TicketEvent) -> List: # TODO: List type? .join(TicketType, SyncTicket.ticket_type_id == TicketType.id) .filter(SyncTicket.ticket_participant_id == TicketParticipant.id) .label('ticket_type_titles'), - cls.user_id.is_not(None).label('has_user'), + cls.participant_id.is_not(None).label('has_user'), ) .select_from(TicketParticipant) .join( @@ -389,14 +390,13 @@ class TicketEventParticipant(BaseMixin, Model): """Join model between :class:`TicketParticipant` and :class:`TicketEvent`.""" __tablename__ = 'ticket_event_participant' - __allow_unmapped__ = True ticket_participant_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_participant.id'), nullable=False ) ticket_participant: Mapped[TicketParticipant] = relationship( TicketParticipant, - backref=sa.orm.backref( + backref=backref( 'ticket_event_participants', cascade='all', overlaps='ticket_events,ticket_participants', @@ -408,7 +408,7 @@ class TicketEventParticipant(BaseMixin, Model): ) ticket_event: Mapped[TicketEvent] = relationship( TicketEvent, - backref=sa.orm.backref( + backref=backref( 'ticket_event_participants', cascade='all', overlaps='ticket_events,ticket_participants', @@ -430,7 +430,7 @@ class TicketEventParticipant(BaseMixin, Model): @classmethod def get( cls, ticket_event: TicketEvent, participant_uuid_b58: str - ) -> Optional[TicketEventParticipant]: + ) -> TicketEventParticipant | None: return ( cls.query.join(TicketParticipant) .filter( @@ -443,7 +443,6 @@ def get( class TicketClient(BaseMixin, Model): __tablename__ = 'ticket_client' - __allow_unmapped__ = True name = with_roles( sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) @@ -463,7 +462,7 @@ class TicketClient(BaseMixin, Model): sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project = with_roles( - 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}, ) @@ -515,7 +514,6 @@ 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.orm.mapped_column(sa.Unicode(80), nullable=False) order_no = sa.orm.mapped_column(sa.Unicode(80), nullable=False) @@ -523,27 +521,27 @@ class SyncTicket(BaseMixin, Model): sa.Integer, sa.ForeignKey('ticket_type.id'), nullable=False ) ticket_type: Mapped[TicketType] = relationship( - TicketType, backref=sa.orm.backref('sync_tickets', cascade='all') + TicketType, backref=backref('sync_tickets', cascade='all') ) ticket_participant_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_participant.id'), nullable=False ) ticket_participant: Mapped[TicketParticipant] = relationship( TicketParticipant, - backref=sa.orm.backref('sync_tickets', cascade='all'), + backref=backref('sync_tickets', cascade='all'), ) ticket_client_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_client.id'), nullable=False ) ticket_client: Mapped[TicketClient] = relationship( - TicketClient, backref=sa.orm.backref('sync_tickets', cascade='all') + TicketClient, backref=backref('sync_tickets', cascade='all') ) __table_args__ = (sa.UniqueConstraint('ticket_client_id', 'order_no', 'ticket_no'),) @classmethod def get( cls, ticket_client: TicketClient, order_no: str, ticket_no: str - ) -> Optional[SyncTicket]: + ) -> SyncTicket | None: return cls.query.filter_by( ticket_client=ticket_client, order_no=order_no, ticket_no=ticket_no ).one_or_none() @@ -580,23 +578,39 @@ def upsert( @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 + # 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', 'project_participant', 'ticket_participant'} + '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 index ffa029493..dfd6abe64 100644 --- a/funnel/models/types.py +++ b/funnel/models/types.py @@ -1,6 +1,6 @@ """Python to SQLAlchemy type mappings.""" -from typing_extensions import Annotated, TypeAlias +from typing import Annotated, TypeAlias import sqlalchemy as sa from sqlalchemy.dialects import postgresql diff --git a/funnel/models/typing.py b/funnel/models/typing.py index 02073e1c3..694350b24 100644 --- a/funnel/models/typing.py +++ b/funnel/models/typing.py @@ -2,33 +2,33 @@ 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 .profile import Profile 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 .user import Organization, Team, User, UserOldId -from .user_session import UserSession from .venue import Venue, VenueRoom __all__ = ['UuidModelUnion', 'SearchModelUnion', 'MarkdownModelUnion'] # All models with a `uuid` attr UuidModelUnion = Union[ + Account, + AccountOldId, AuthClient, Comment, CommentModeratorReport, Commentset, ImmutableMembershipMixin, - Organization, - Profile, + LoginSession, Project, Proposal, Rsvp, @@ -36,19 +36,14 @@ Team, TicketParticipant, Update, - User, - UserOldId, - UserSession, Venue, VenueRoom, ] # All models with a `search_vector` attr -SearchModelUnion = Union[ - Comment, Label, Organization, Profile, Project, Proposal, Session, Update, User -] +SearchModelUnion = Union[Account, Comment, Label, Project, Proposal, Session, Update] # All models with one or more markdown composite columns MarkdownModelUnion = Union[ - Comment, Profile, Project, Proposal, Session, Update, Venue, VenueRoom + Account, Comment, Project, Proposal, Session, Update, Venue, VenueRoom ] diff --git a/funnel/models/update.py b/funnel/models/update.py index ec2c8e153..68a28157d 100644 --- a/funnel/models/update.py +++ b/funnel/models/update.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional, Sequence +from collections.abc import Sequence from sqlalchemy.orm import Query as BaseQuery @@ -13,20 +13,25 @@ from . import ( BaseScopedIdNameMixin, Mapped, - MarkdownCompositeDocument, Model, Query, TimestampMixin, 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 .user import User __all__ = ['Update'] @@ -44,7 +49,6 @@ class VISIBILITY_STATE(LabeledEnum): # noqa: N801 class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, Model): __tablename__ = 'update' - __allow_unmapped__ = True _visibility_state = sa.orm.mapped_column( 'visibility_state', @@ -68,14 +72,14 @@ class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, Model): ) state = StateManager('_state', UPDATE_STATE, doc="Update state") - user_id = sa.orm.mapped_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( + created_by: Mapped[Account] = with_roles( relationship( - User, - backref=sa.orm.backref('updates', lazy='dynamic'), - foreign_keys=[user_id], + Account, + backref=backref('updates_created', lazy='dynamic'), + foreign_keys=[created_by_id], ), read={'all'}, grants={'creator'}, @@ -85,7 +89,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, Model): sa.Integer, sa.ForeignKey('project.id'), nullable=False, index=True ) project: Mapped[Project] = with_roles( - relationship(Project, backref=sa.orm.backref('updates', lazy='dynamic')), + relationship(Project, backref=backref('updates', lazy='dynamic')), read={'all'}, datasets={'primary'}, grants_via={ @@ -132,13 +136,13 @@ class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, Model): sa.orm.mapped_column(sa.Boolean, default=False, nullable=False), read={'all'} ) - published_by_id = sa.orm.mapped_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( + published_by: Mapped[Account | None] = with_roles( relationship( - User, - backref=sa.orm.backref('published_updates', lazy='dynamic'), + Account, + backref=backref('published_updates', lazy='dynamic'), foreign_keys=[published_by_id], ), read={'all'}, @@ -147,13 +151,13 @@ class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, Model): sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'} ) - deleted_by_id = sa.orm.mapped_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( + deleted_by: Mapped[Account | None] = with_roles( relationship( - User, - backref=sa.orm.backref('deleted_updates', lazy='dynamic'), + Account, + backref=backref('deleted_updates', lazy='dynamic'), foreign_keys=[deleted_by_id], ), read={'reader'}, @@ -177,7 +181,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, Model): lazy='joined', cascade='all', single_parent=True, - backref=sa.orm.backref('update', uselist=False), + backref=backref('update', uselist=False), ), read={'all'}, ) @@ -215,7 +219,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, Model): 'body_html', 'published_at', 'edited_at', - 'user', + 'created_by', 'is_pinned', 'is_restricted', 'is_currently_restricted', @@ -233,7 +237,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, TimestampMixin, Model): 'body_html', 'published_at', 'edited_at', - 'user', + 'created_by', 'is_pinned', 'is_restricted', 'is_currently_restricted', @@ -283,7 +287,7 @@ def state_label(self) -> str: @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: @@ -304,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) @@ -349,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: Sequence = () + self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: roles = super().roles_for(actor, anchors) if not self.visibility_state.RESTRICTED: @@ -367,7 +371,7 @@ def all_published_public(cls) -> Query[Update]: ) @with_roles(read={'all'}) - def getnext(self) -> Optional[Update]: + def getnext(self) -> Update | None: """Get next published update.""" if self.state.PUBLISHED: return ( @@ -382,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 ( @@ -421,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 a0175fd94..000000000 --- a/funnel/models/user.py +++ /dev/null @@ -1,2040 +0,0 @@ -"""User, organization, team and user anchor models.""" - -from __future__ import annotations - -import hashlib -import itertools -from datetime import timedelta -from typing import Iterable, Iterator, List, Optional, Set, Union, cast, overload -from typing_extensions import Literal -from uuid import UUID - -import phonenumbers -from passlib.hash import argon2, bcrypt -from sqlalchemy.ext.associationproxy import association_proxy -from werkzeug.utils import cached_property - -from baseframe import __ -from coaster.sqlalchemy import ( - 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, - DynamicMapped, - LocaleType, - Mapped, - Model, - Query, - TimezoneType, - TSVectorType, - UuidMixin, - db, - declarative_mixin, - hybrid_property, - relationship, - 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, Model): - """User model.""" - - __tablename__ = 'user' - __allow_unmapped__ = True - __title_length__ = 80 - - #: The user's fullname - fullname: Mapped[str] = with_roles( - sa.orm.mapped_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.orm.mapped_column(sa.Unicode, nullable=True) - #: Timestamp for when the user's password last changed - pw_set_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) - #: Expiry date for the password (to prompt user to reset it) - pw_expires_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) - #: User's preferred/last known timezone - timezone = with_roles( - sa.orm.mapped_column(TimezoneType(backend='pytz'), nullable=True), - read={'owner'}, - ) - #: Update timezone automatically from browser activity - auto_timezone = sa.orm.mapped_column(sa.Boolean, default=True, nullable=False) - #: User's preferred/last known locale - locale = with_roles(sa.orm.mapped_column(LocaleType, nullable=True), read={'owner'}) - #: Update locale automatically from browser activity - auto_locale = sa.orm.mapped_column(sa.Boolean, default=True, nullable=False) - #: User's state code (active, suspended, merged, deleted) - _state = sa.orm.mapped_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.mapped_column( - TSVectorType( - 'fullname', - weights={'fullname': 'A'}, - regconfig='english', - hltext=lambda: User.fullname, - ), - nullable=False, - deferred=True, - ) - - __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) -> 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), - ] - - 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.inplace.setter - def _name_setter(self, value: Optional[str]) -> None: - """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.inplace.expression - @classmethod - def _name_expression(cls) -> sa.Label: - """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) - - #: 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[Model] = None - ) -> 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[Model] = None) -> 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: Optional[Model] = 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: Optional[Model] = 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: Optional[Model] = 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: Optional[Model] = 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: Optional[Model] = None - ) -> 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: Optional[Model] = 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), Profile.name_in(usernames)) - ) - elif buids: - query = cls.query.filter(cls.buid.in_(buids)) - 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, Model): - """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] = relationship( - User, - primaryjoin='foreign(UserOldId.id) == remote(User.uuid)', - backref=sa.orm.backref('oldid', uselist=False), - ) - #: User id of new user - user_id = sa.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - #: New user account - user: Mapped[User] = 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', - Model.metadata, - 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, Model -): - """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.orm.mapped_column(sa.Unicode(__title_length__), default='', nullable=False), - read={'all'}, - ) - - #: Organization's state (active, suspended) - _state = sa.orm.mapped_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.mapped_column( - TSVectorType( - 'title', - weights={'title': 'A'}, - regconfig='english', - hltext=lambda: Organization.title, - ), - nullable=False, - deferred=True, - ) - - __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) -> List[sa.orm.interfaces.LoaderOption]: - """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.inplace.setter - def _name_setter(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(name=value, organization=self, uuid=self.uuid) - db.session.add(self.profile) - - @name.inplace.expression - @classmethod - def _name_expression(cls) -> sa.Label: - """Return @name from linked profile as a SQL expression.""" - return ( - 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[User]: - """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)) - 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, Model): - """A team of users within an organization.""" - - __tablename__ = 'team' - __allow_unmapped__ = True - __title_length__ = 250 - #: Displayed name - title = sa.orm.mapped_column(sa.Unicode(__title_length__), nullable=False) - #: Organization - organization_id = sa.orm.mapped_column( - sa.Integer, sa.ForeignKey('organization.id'), nullable=False - ) - organization = with_roles( - relationship( - Organization, - backref=sa.orm.backref( - 'teams', order_by=sa.func.lower(title), cascade='all' - ), - ), - grants_via={None: {'owner': 'owner', 'admin': 'admin'}}, - ) - users: DynamicMapped[User] = with_roles( - relationship(User, secondary=team_membership, lazy='dynamic', backref='teams'), - grants={'subject'}, - ) - - is_public = 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_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, Model): - """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] # type: ignore[assignment] - - user_id = sa.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = relationship( - User, backref=sa.orm.backref('emails', cascade='all') - ) - - private = sa.orm.mapped_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, Model): - """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] # type: ignore[assignment] - email: str - - user_id = sa.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = relationship( - User, backref=sa.orm.backref('emailclaims', cascade='all') - ) - verification_code = sa.orm.mapped_column( - sa.String(44), nullable=False, default=newsecret - ) - - private = sa.orm.mapped_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[UserEmailClaim]: # 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, Model): - """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.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - user: Mapped[User] = relationship( - User, backref=sa.orm.backref('phones', cascade='all') - ) - - private = sa.orm.mapped_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, Model): - """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.orm.mapped_column(sa.Integer, sa.ForeignKey('user.id'), nullable=False) - #: User that this connected account belongs to - user: Mapped[User] = relationship( - User, backref=sa.orm.backref('externalids', cascade='all') - ) - #: Identity of the external service (in app's login provider registry) - service = sa.orm.mapped_column(sa.UnicodeText, nullable=False) - #: Unique user id as per external service, used for identifying related accounts - userid = sa.orm.mapped_column( - sa.UnicodeText, nullable=False - ) # Unique id (or obsolete OpenID) - #: Optional public-facing username on the external service - username = sa.orm.mapped_column( - sa.UnicodeText, nullable=True - ) # LinkedIn once used full URLs - #: OAuth or OAuth2 access token - oauth_token = sa.orm.mapped_column(sa.UnicodeText, nullable=True) - #: Optional token secret (not used in OAuth2, used by Twitter with OAuth1a) - oauth_token_secret = sa.orm.mapped_column(sa.UnicodeText, nullable=True) - #: OAuth token type (typically 'bearer') - oauth_token_type = sa.orm.mapped_column(sa.UnicodeText, nullable=True) - #: OAuth2 refresh token - oauth_refresh_token = sa.orm.mapped_column(sa.UnicodeText, nullable=True) - #: OAuth2 token expiry in seconds, as sent by service provider - oauth_expires_in = sa.orm.mapped_column(sa.Integer, nullable=True) - #: OAuth2 token expiry timestamp, estimate from created_at + oauth_expires_in - oauth_expires_at = 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 = sa.orm.mapped_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_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 47c4c9242..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_extensions import Literal +from typing import Literal, NamedTuple, overload import phonenumbers from sqlalchemy import PrimaryKeyConstraint, UniqueConstraint from .. import app from ..typing import OptionalMigratedTables -from . import Model +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,64 +102,87 @@ 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 @@ -156,7 +190,7 @@ def merge_users(user1: User, user2: User) -> Optional[User]: def do_migrate_instances( old_instance: Model, new_instance: Model, - helper_method: Optional[str] = None, + helper_method: str | None = None, ) -> bool: """ Migrate references to old instance of any model to provided 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" diff --git a/funnel/models/venue.py b/funnel/models/venue.py index db182a18e..5f52e5443 100644 --- a/funnel/models/venue.py +++ b/funnel/models/venue.py @@ -3,7 +3,6 @@ from __future__ import annotations import itertools -from typing import List from sqlalchemy.ext.orderinglist import ordering_list @@ -13,13 +12,12 @@ BaseScopedNameMixin, CoordinatesMixin, Mapped, - MarkdownCompositeBasic, Model, UuidMixin, relationship, sa, ) -from .helpers import reopen +from .helpers import MarkdownCompositeBasic, reopen from .project import Project from .project_membership import project_child_role_map @@ -28,7 +26,6 @@ class Venue(UuidMixin, BaseScopedNameMixin, CoordinatesMixin, Model): __tablename__ = 'venue' - __allow_unmapped__ = True project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False @@ -48,7 +45,7 @@ class Venue(UuidMixin, BaseScopedNameMixin, CoordinatesMixin, Model): 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( + rooms: Mapped[list[VenueRoom]] = relationship( 'VenueRoom', cascade='all', order_by='VenueRoom.seq', @@ -109,7 +106,6 @@ class Venue(UuidMixin, BaseScopedNameMixin, CoordinatesMixin, Model): class VenueRoom(UuidMixin, BaseScopedNameMixin, Model): __tablename__ = 'venue_room' - __allow_unmapped__ = True venue_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('venue.id'), nullable=False diff --git a/funnel/models/video_mixin.py b/funnel/models/video_mixin.py index 980c0b75a..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: @@ -80,7 +78,7 @@ class VideoMixin: 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 b98ceb12a..c709ebb7a 100644 --- a/funnel/proxies/request.py +++ b/funnel/proxies/request.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Callable from functools import wraps -from typing import TYPE_CHECKING, Callable, Optional, Set +from typing import TYPE_CHECKING from flask import has_request_context, request from werkzeug.local import LocalProxy @@ -16,7 +17,7 @@ def test_uses( *headers: str, -) -> Callable[[Callable[[RequestWants], T]], cached_property[Optional[T]]]: +) -> Callable[[Callable[[RequestWants], T]], cached_property[T | None]]: """ Identify HTTP headers accessed in this test, to be set in the response Vary header. @@ -24,9 +25,9 @@ def test_uses( method into a cached property. """ - def decorator(f: Callable[[RequestWants], T]) -> cached_property[Optional[T]]: + def decorator(f: Callable[[RequestWants], T]) -> cached_property[T | None]: @wraps(f) - def wrapper(self: RequestWants) -> Optional[T]: + def wrapper(self: RequestWants) -> T | None: self.response_vary.update(headers) if not has_request_context(): return None @@ -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,22 +96,22 @@ 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') diff --git a/funnel/registry.py b/funnel/registry.py index 7b42d6414..fe75600ea 100644 --- a/funnel/registry.py +++ b/funnel/registry.py @@ -4,9 +4,10 @@ import re from collections import OrderedDict +from collections.abc import Callable, Collection from dataclasses import dataclass from functools import wraps -from typing import Any, Callable, Collection, List, NoReturn, Optional, Tuple +from typing import Any, NoReturn from flask import Response, abort, jsonify, request from werkzeug.datastructures import MultiDict @@ -14,7 +15,7 @@ from baseframe import _ from baseframe.signals import exception_catchall -from .models import AuthToken, UserExternalId +from .models import AccountExternalId, AuthToken from .typing import P, ReturnResponse # Bearer token, as per @@ -28,9 +29,9 @@ class ResourceRegistry(OrderedDict): def resource( self, name: str, - description: Optional[str] = None, + description: str | None = None, trusted: bool = False, - scope: Optional[str] = None, + scope: str | None = None, ) -> Callable[[Callable[P, Any]], Callable[[], ReturnResponse]]: """ Decorate a resource function. @@ -146,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): @@ -231,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/signals.py b/funnel/signals.py index 50fda1aad..8681036f1 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') 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/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/account.html.jinja2 b/funnel/templates/account.html.jinja2 index 33b6c41f1..0d8581457 100644 --- a/funnel/templates/account.html.jinja2 +++ b/funnel/templates/account.html.jinja2 @@ -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) }} + @@ -116,7 +117,10 @@ {% for extid in current_auth.user.externalids %} {{ 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") }} @@ -180,7 +184,9 @@ {{ 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") }} + {% endfor %} @@ -228,9 +234,9 @@ {{ 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 %} @@ -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 %} - {% trans ipaddr=user_session.ipaddr %}{{ location }} – estimated from {{ ipaddr }}{% endtrans %} + {% trans ipaddr=login_session.ipaddr %}{{ location }} – estimated from {{ ipaddr }}{% endtrans %} - {%- if user_session == current_auth.session %} + {%- if login_session == current_auth.session %} {{ faicon(icon='check-circle-solid', icon_size='subhead', baseline=true, css_class="mui--text-success input-align-icon") }} {%- else -%} diff --git a/funnel/templates/account_menu.html.jinja2 b/funnel/templates/account_menu.html.jinja2 index 5902b9eba..2971a01f8 100644 --- a/funnel/templates/account_menu.html.jinja2 +++ b/funnel/templates/account_menu.html.jinja2 @@ -1,6 +1,6 @@ {%- from "macros.html.jinja2" import faicon, useravatar, csrf_tag, img_size %} {%- if current_auth -%} - {% if current_auth.user.profile %} + {% if current_auth.user.name %} @@ -44,18 +44,18 @@ {%- for orgmem in orgmemlist.recent %} - - {%- if orgmem.organization.profile.logo_url.url %} - + {%- if orgmem.account.logo_url.url %} + {% else %} + alt="{{ orgmem.account.title }}"/> {% endif %} - {{ orgmem.organization.profile.title }} + {{ orgmem.account.title }} {%- endfor %} @@ -65,12 +65,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 %} - + {%- if orgmem.account.logo_url.url %} + {% else %} + alt="{{ orgmem.account.title }}"/> {% endif %} {%- endfor %} diff --git a/funnel/templates/account_merge.html.jinja2 b/funnel/templates/account_merge.html.jinja2 index f65c97875..15605b16e 100644 --- a/funnel/templates/account_merge.html.jinja2 +++ b/funnel/templates/account_merge.html.jinja2 @@ -8,8 +8,8 @@ {%- if user.emails %} {% trans %}Email addresses:{% endtrans %} - {%- for useremail in user.emails %} - {{ useremail.email }} + {%- for accemail in user.emails %} + {{ accemail.email }} {%- endfor %} diff --git a/funnel/templates/account_organizations.html.jinja2 b/funnel/templates/account_organizations.html.jinja2 index b538e940b..65072ad0d 100644 --- a/funnel/templates/account_organizations.html.jinja2 +++ b/funnel/templates/account_organizations.html.jinja2 @@ -30,25 +30,25 @@ {% for orgmem in current_auth.user.views.organizations_as_admin() %} - - {%- if orgmem.organization.profile.logo_url.url %} - + {%- if orgmem.account.logo_url.url %} + {% else %} + alt="{{ orgmem.account.title }}"/> {% endif %} - - {{ 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/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 %} - +
- {% trans ipaddr=user_session.ipaddr %}{{ location }} – estimated from {{ ipaddr }}{% endtrans %} + {% trans ipaddr=login_session.ipaddr %}{{ location }} – estimated from {{ ipaddr }}{% endtrans %}
- {{ 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 %}
- {%- for user in orgmem.organization.admin_users %} + {%- for user in orgmem.account.admin_users %} {{ user.pickername }} {%- if not loop.last %},{% endif %} {%- endfor %}
{% trans %}The following users have permissions to this app{% endtrans %}
{% trans %}The following teams have permissions to this app{% endtrans %}