diff --git a/.github/workflows/docker-ci-tests.yml b/.github/workflows/docker-ci-tests.yml index bf055aeeb..b70669874 100644 --- a/.github/workflows/docker-ci-tests.yml +++ b/.github/workflows/docker-ci-tests.yml @@ -1,4 +1,4 @@ -name: 'Pytest on docker' +name: Pytest Docker on: push: @@ -25,7 +25,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - test: + pytest-docker: + name: Pytest Docker runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a3098240e..22b56bf67 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -26,7 +26,9 @@ concurrency: cancel-in-progress: true jobs: - test: + pytest: + name: Pytest + timeout-minutes: 10 runs-on: ${{ matrix.os }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -125,7 +127,11 @@ jobs: psql -h localhost -U postgres geoname_testing -c "grant all privileges on schema public to $(whoami); grant all privileges on all tables in schema public to $(whoami); grant all privileges on all sequences in schema public to $(whoami);" - name: Test with pytest run: | - pytest --disable-warnings --gherkin-terminal-reporter -vv --showlocals --cov=funnel + pytest --disable-warnings --gherkin-terminal-reporter -vv --showlocals --ignore=tests/e2e + - name: Browser tests with pytest + timeout-minutes: 5 + run: | + pytest --disable-warnings --gherkin-terminal-reporter -vv --showlocals --cov-append --cov=funnel tests/e2e - name: Prepare coverage report run: | mkdir -p coverage @@ -139,7 +145,7 @@ jobs: parallel: true finish: - needs: test + needs: pytest runs-on: ubuntu-latest steps: - name: Publish to Coveralls diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d43f58930..d54c51fbc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,6 @@ default_language_version: ci: skip: [ 'pip-audit', - 'yesqa', 'creosote', 'no-commit-to-branch', # 'hadolint-docker', @@ -29,7 +28,7 @@ repos: - id: pip-compile-multi-verify files: ^requirements/.*\.(in|txt)$ - repo: https://github.com/pypa/pip-audit - rev: v2.7.0 + rev: v2.7.3 hooks: - id: pip-audit args: [ @@ -51,75 +50,18 @@ repos: ] files: ^requirements/.*\.txt$ - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.15.2 hooks: - id: pyupgrade args: ['--keep-runtime-typing', '--py311-plus'] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.0 + rev: v0.4.6 hooks: - id: ruff args: ['--fix', '--exit-non-zero-on-fix'] - # Extra args, only after removing flake8 and yesqa: '--extend-select', 'RUF100' - - repo: https://github.com/lucasmbrown/mirrors-autoflake - rev: v1.3 - hooks: - - id: autoflake - args: - [ - '--in-place', - '--remove-all-unused-imports', - '--ignore-init-module-imports', - '--remove-unused-variables', - '--remove-duplicate-keys', - ] - - repo: https://github.com/asottile/yesqa - rev: v1.5.0 - hooks: - - id: yesqa - additional_dependencies: &flake8deps - - bandit - # - flake8-annotations - - flake8-assertive - - flake8-blind-except - - flake8-bugbear - - flake8-builtins - - flake8-comprehensions - - flake8-docstrings - - flake8-isort - - flake8-logging-format - - flake8-mutable - - flake8-plugin-utils - - flake8-print - - flake8-pytest-style - - pep8-naming - - toml - - tomli - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort - additional_dependencies: - - tomli - - repo: https://github.com/psf/black - rev: 24.1.1 - hooks: - - id: black - - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 - hooks: - - id: flake8 - additional_dependencies: *flake8deps - - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 - hooks: - - id: flake8 - name: flake8-pyi - types: [pyi] - additional_dependencies: - - flake8-pyi + - id: ruff-format - repo: https://github.com/PyCQA/pylint - rev: v3.0.3 + rev: v3.2.2 hooks: - id: pylint args: [ @@ -130,26 +72,18 @@ repos: ] additional_dependencies: - tomli - - repo: https://github.com/PyCQA/bandit - rev: 1.7.7 - hooks: - - id: bandit - language_version: python3 - args: ['-c', 'pyproject.toml'] - additional_dependencies: - - 'bandit[toml]' - repo: https://github.com/fredrikaverpil/creosote rev: v3.0.0 hooks: - id: creosote - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.9.0.6 + rev: v0.10.0.1 hooks: - id: shellcheck args: - --external-sources - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-added-large-files - id: check-ast @@ -183,7 +117,7 @@ repos: - id: trailing-whitespace args: ['--markdown-linebreak-ext=md'] - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.4 + rev: v1.5.5 hooks: - id: forbid-crlf - id: remove-crlf diff --git a/.testenv b/.testenv index 12a9accf4..0351e10f4 100644 --- a/.testenv +++ b/.testenv @@ -4,6 +4,8 @@ FLASK_ENV=testing FLASK_TESTING=true FLASK_DEBUG_TB_ENABLED=false +# Disable Recaptcha +FLASK_RECAPTCHA_DISABLED=true # Enable CSRF so tests reflect production use FLASK_WTF_CSRF_ENABLED=true # Use Redis cache so that rate limit validation tests work, with Redis db diff --git a/Makefile b/Makefile index 22d701d81..66c7ae1a2 100644 --- a/Makefile +++ b/Makefile @@ -120,30 +120,30 @@ initpy: initpy-models initpy-forms initpy-loginproviders initpy-transports initp initpy-models: mkinit --inplace --relative --black --lazy_loader_typed funnel/models/__init__.py - isort funnel/models/__init__.py funnel/models/__init__.pyi - black funnel/models/__init__.py funnel/models/__init__.pyi + ruff check --fix funnel/models/__init__.py funnel/models/__init__.pyi + ruff format funnel/models/__init__.py funnel/models/__init__.pyi initpy-forms: mkinit --inplace --relative --black funnel/forms/__init__.py - isort funnel/forms/__init__.py - black funnel/forms/__init__.py + ruff check --fix funnel/forms/__init__.py + ruff format funnel/forms/__init__.py initpy-loginproviders: mkinit --inplace --relative --black funnel/loginproviders/__init__.py - isort funnel/loginproviders/__init__.py - black funnel/loginproviders/__init__.py + ruff check --fix funnel/loginproviders/__init__.py + ruff format funnel/loginproviders/__init__.py initpy-transports: # Do not auto-gen funnel/transports/__init__.py, only sub-packages mkinit --inplace --relative --black funnel/transports/email - mkinit --inplace --relative --black funnel/transports/sms - isort funnel/transports/*/__init__.py - black funnel/transports/*/__init__.py + mkinit --inplace --relative --black funnel/transports/sms + ruff check --fix funnel/transports/*/__init__.py + ruff format funnel/transports/*/__init__.py initpy-utils: mkinit --inplace --relative --black --recursive funnel/utils - isort funnel/utils/__init__.py funnel/utils/*/__init__.py funnel/utils/*/*/__init__.py - black funnel/utils/__init__.py funnel/utils/*/__init__.py funnel/utils/*/*/__init__.py + ruff check --fix funnel/utils/__init__.py funnel/utils/*/__init__.py funnel/utils/*/*/__init__.py + ruff format funnel/utils/__init__.py funnel/utils/*/__init__.py funnel/utils/*/*/__init__.py install-npm: npm install diff --git a/devserver.py b/devserver.py index a8ce61ecf..2d0da118d 100755 --- a/devserver.py +++ b/devserver.py @@ -3,8 +3,10 @@ import os import sys -from typing import Any +import warnings +from typing import Any, Literal, cast +import rich.traceback from flask.cli import load_dotenv from werkzeug import run_simple @@ -19,13 +21,34 @@ def rq_background_worker(*args: Any, **kwargs: Any) -> Any: if __name__ == '__main__': + rich.traceback.install(show_locals=True, width=None) load_dotenv() - sys.path.insert(0, os.path.dirname(__file__)) + script_dir = os.path.dirname(__file__) + if script_dir != '.' and not script_dir.endswith('/.'): + # If this script is not running from the current working directory, add it's + # path to the Python path so imports work + sys.path.insert(0, script_dir) os.environ['FLASK_ENV'] = 'development' # Needed for coaster.app.init_app os.environ.setdefault('FLASK_DEBUG', '1') debug_mode = os.environ['FLASK_DEBUG'].lower() not in {'0', 'false', 'no'} + ssl_context: str | Literal['adhoc'] | tuple[str, str] | None # noqa: PYI051 + ssl_context = os.environ.get('FLASK_DEVSERVER_HTTPS') + if ssl_context is not None: + if not ssl_context: + ssl_context = None # Recast empty string as None + elif ssl_context == 'adhoc': + # For type checkers to narrow to a literal value + ssl_context = cast(Literal['adhoc'], ssl_context) + elif ':' in ssl_context: + ssl_context = cast(tuple[str, str], tuple(ssl_context.split(':', 1))) + else: + warnings.warn( + f"FLASK_DEVSERVER_HTTPS env var has invalid value {ssl_context!r}", + stacklevel=1, + ) + ssl_context = None - from funnel.devtest import BackgroundWorker, devtest_app + from funnel.devtest import BackgroundWorker, RichDebuggedApplication, devtest_app # Set debug mode on apps devtest_app.debug = debug_mode @@ -35,18 +58,25 @@ def rq_background_worker(*args: Any, **kwargs: Any) -> Any: # Only start RQ worker within the reloader environment background_rq = BackgroundWorker( rq_background_worker, - mock_transports=bool(getbool(os.environ.get('MOCK_TRANSPORTS', False))), + mock_transports=bool(getbool(os.environ.get('MOCK_TRANSPORTS', True))), ) background_rq.start() + if debug_mode: + run_app: Any = RichDebuggedApplication( + devtest_app, evalex=True, console_path='/_console' + ) + else: + run_app = devtest_app + run_simple( os.environ.get('FLASK_RUN_HOST', '127.0.0.1'), int(os.environ.get('FLASK_RUN_PORT', 3000)), - devtest_app, + run_app, use_reloader=True, - use_debugger=debug_mode, - use_evalex=debug_mode, + use_debugger=False, # Since we've already wrapped the app in the debugger threaded=True, + ssl_context=ssl_context, extra_files=['funnel/static/build/manifest.json'], ) diff --git a/funnel/__init__.py b/funnel/__init__.py index 534228ceb..680f453ef 100644 --- a/funnel/__init__.py +++ b/funnel/__init__.py @@ -26,20 +26,20 @@ #: Main app for hasgeek.com app = Flask(__name__, instance_relative_config=True) -app.name = 'funnel' # pyright: ignore[reportGeneralTypeIssues] +app.name = 'funnel' # pyright: ignore[reportAttributeAccessIssue] app.config['SITE_TITLE'] = __("Hasgeek") #: Short link app at has.gy shortlinkapp = Flask(__name__, static_folder=None, instance_relative_config=True) -shortlinkapp.name = 'shortlink' # pyright: ignore[reportGeneralTypeIssues] +shortlinkapp.name = 'shortlink' # pyright: ignore[reportAttributeAccessIssue] #: Unsubscribe app at bye.li unsubscribeapp = Flask(__name__, static_folder=None, instance_relative_config=True) -unsubscribeapp.name = 'unsubscribe' # pyright: ignore[reportGeneralTypeIssues] +unsubscribeapp.name = 'unsubscribe' # pyright: ignore[reportAttributeAccessIssue] all_apps = [app, shortlinkapp, unsubscribeapp] mail = Mail() pages = FlatPages() -manifest = WebpackManifest(filepath='static/build/manifest.json') +webpack = WebpackManifest(filepath='static/build/manifest.json', jinja_global='webpack') redis_store = FlaskRedis(decode_responses=True, config_prefix='CACHE_REDIS') rq = RQ() @@ -47,7 +47,7 @@ rq.queues = ['funnel'] # Queues used in this app executor = Executor() -# --- Assets --------------------------------------------------------------------------- +# MARK: Assets ------------------------------------------------------------------------- #: Theme files, for transitioning away from Baseframe templates. These are used by #: Baseframe's render_form and other form helper functions. @@ -66,7 +66,7 @@ assets['schedules.js'][version] = 'js/schedules.js' -# --- Import rest of the app ----------------------------------------------------------- +# MARK: Import rest of the app --------------------------------------------------------- from . import ( # isort:skip # noqa: F401 # pylint: disable=wrong-import-position geoip, @@ -81,7 +81,7 @@ ) from .models import db, sa_orm # isort:skip -# --- Configuration--------------------------------------------------------------------- +# MARK: Configuration ------------------------------------------------------------------ # Config is loaded from legacy Python settings files in the instance folder and then # overridden with values from the environment. Python config is pending deprecation @@ -117,7 +117,7 @@ phonenumbers.PhoneNumberFormat.INTERNATIONAL, ) proxies.init_app(each_app) - manifest.init_app(each_app) + webpack.init_app(each_app) db.init_app(each_app) mail.init_app(each_app) @@ -197,13 +197,13 @@ views.siteadmin.init_rq_dashboard() -# --- Serve static files with WhiteNoise ----------------------------------------------- +# MARK: Serve static files with WhiteNoise --------------------------------------------- _wn = WhiteNoise(app.wsgi_app, root=app.static_folder, prefix=app.static_url_path) _wn.add_files(baseframe.static_folder, prefix=baseframe.static_url_path) app.wsgi_app = _wn # type: ignore[method-assign] -# --- Init SQLAlchemy mappers ---------------------------------------------------------- +# MARK: Init SQLAlchemy mappers -------------------------------------------------------- # Database model loading (from Funnel or extensions) is complete. # Configure database mappers now, before the process is forked for workers. diff --git a/funnel/assets/js/app.js b/funnel/assets/js/app.js index 92666baeb..1249d8cca 100644 --- a/funnel/assets/js/app.js +++ b/funnel/assets/js/app.js @@ -13,6 +13,7 @@ import ReadStatus from './utils/read_status'; import LazyLoadMenu from './utils/lazyloadmenu'; import './utils/getDevicePixelRatio'; import setTimezoneCookie from './utils/timezone'; +import './utils/follow_action'; import 'muicss/dist/js/mui'; const pace = require('pace-js'); diff --git a/funnel/assets/js/autosave_form.js b/funnel/assets/js/autosave_form.js index 54f0bb1b9..e5ff93f14 100644 --- a/funnel/assets/js/autosave_form.js +++ b/funnel/assets/js/autosave_form.js @@ -43,9 +43,6 @@ window.Hasgeek.autoSave = ({ autosave, formId, msgElemId }) => { if (remoteData.revision) { $('input[name="form.revision"]').val(remoteData.revision); } - if (remoteData.form_nonce) { - $('input[name="form_nonce"]').val(remoteData.form_nonce); - } waitingForResponse = false; } } else { diff --git a/funnel/assets/js/profile.js b/funnel/assets/js/profile.js new file mode 100644 index 000000000..2ce87f5d9 --- /dev/null +++ b/funnel/assets/js/profile.js @@ -0,0 +1 @@ +import 'htmx.org'; diff --git a/funnel/assets/js/profile_calendar.js b/funnel/assets/js/profile_calendar.js new file mode 100644 index 000000000..0547745f8 --- /dev/null +++ b/funnel/assets/js/profile_calendar.js @@ -0,0 +1,129 @@ +import Vue from 'vue/dist/vue.min'; +import FullCalendar from '@fullcalendar/vue'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import multiMonthPlugin from '@fullcalendar/multimonth'; +import { faSvg } from './utils/vue_util'; + +$(() => { + /* eslint-disable no-new */ + const calendarApp = new Vue({ + el: '#calendar', + components: { + FullCalendar, + faSvg, + }, + data() { + return { + date: '', + showFilter: false, + loading: false, + calendarView: 'monthly', + access: 'both', + cfp: '', + events: [], + calendarOptions: { + plugins: [dayGridPlugin, multiMonthPlugin], + initialView: 'dayGridMonth', + aspectRatio: 1.5, + headerToolbar: { + start: 'title', + center: '', + end: '', + }, + showNonCurrentDates: false, + dayMaxEventRows: 1, + events: async function fetchEvents(info) { + const url = `${window.location.href}?${new URLSearchParams({ + start: info.startStr, + end: info.endStr, + }).toString()}`; + const response = await fetch(url, { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (response && response.ok) { + const responseData = await response.json(); + calendarApp.events = responseData; + return responseData; + } + return false; + }, + lazyFetching: false, + eventTimeFormat: { + // like '14:30:00' + hour: '2-digit', + minute: '2-digit', + meridiem: 'short', + }, + }, + }; + }, + mounted() { + this.calendar = this.$refs.fullCalendar.getApi(); + this.updateTitle(); + }, + methods: { + updateTitle() { + this.date = this.calendar.currentData.viewTitle; + }, + updateEvents() { + this.events = this.calendar.getEvents(); + }, + prev() { + this.loading = true; + this.calendar.prev(); + this.updateTitle(); + }, + next() { + this.loading = true; + this.calendar.next(); + this.updateTitle(); + }, + toggleFilterMenu() { + this.showFilter = !this.showFilter; + }, + applyFilter() { + this.loading = true; + this.showFilter = false; // Close filter menu + switch (this.calendarView) { + case 'monthly': + this.calendar.changeView('dayGridMonth'); + break; + case 'yearly': + this.calendar.changeView('multiMonthYear'); + break; + default: + this.calendar.changeView('dayGridMonth'); + } + this.updateTitle(); + this.updateEvents(); + }, + propertyVal(event, key) { + return ( + event && (event[key] || (event.extendedProps && event.extendedProps[key])) + ); + }, + }, + computed: { + filteredEvents() { + return this.events + .filter((event) => { + if (this.access === 'member') return event.member_access; + if (this.access === 'free') return !event.member_access; + return event; + }) + .filter((event) => { + if (this.cfp) return event.cfp_open === this.cfp; + return event; + }); + }, + }, + watch: { + events() { + this.loading = false; + }, + }, + }); +}); diff --git a/funnel/assets/js/shortlink_form.js b/funnel/assets/js/shortlink_form.js index 3a30c1d5e..57c7f8889 100644 --- a/funnel/assets/js/shortlink_form.js +++ b/funnel/assets/js/shortlink_form.js @@ -32,7 +32,7 @@ $(() => { $(copyBtn).on('click', function clickCopyLink(event) { event.preventDefault(); - Utils.copyToClipboard(shortlinkBox); + Utils.copyToClipboard($(shortlinkBox)[0]); }); $(form).on('submit', (event) => { diff --git a/funnel/assets/js/submission.js b/funnel/assets/js/submission.js index 5782f54e6..7b8dcee30 100644 --- a/funnel/assets/js/submission.js +++ b/funnel/assets/js/submission.js @@ -33,7 +33,6 @@ export const Submission = { toastr.success(responseData.message); } $('.js-subscribed, .js-unsubscribed').toggleClass('mui--hide'); - Form.updateFormNonce(responseData); } } else { Form.getFetchError(response); diff --git a/funnel/assets/js/update.js b/funnel/assets/js/update.js index 11e673bb7..5e0ec16f7 100644 --- a/funnel/assets/js/update.js +++ b/funnel/assets/js/update.js @@ -52,9 +52,8 @@ const Updates = { }, handlePinEvent(event, formId, postUrl) { event.preventDefault(); - const onSuccess = (response) => { + const onSuccess = () => { this.update.is_pinned = !this.update.is_pinned; - Form.updateFormNonce(response); }; const onError = (error) => { diff --git a/funnel/assets/js/utils/bookmark.js b/funnel/assets/js/utils/bookmark.js index 395c210cc..c127ed320 100644 --- a/funnel/assets/js/utils/bookmark.js +++ b/funnel/assets/js/utils/bookmark.js @@ -6,7 +6,7 @@ const SaveProject = ({ postUrl = $(`#${formId}`).attr('action'), config = {}, }) => { - const onSuccess = (response) => { + const onSuccess = () => { $(`#${formId}`) .find('button') .prop('disabled', false) @@ -25,7 +25,6 @@ const SaveProject = ({ } } }); - Form.updateFormNonce(response); }; const onError = (error) => { diff --git a/funnel/assets/js/utils/follow_action.js b/funnel/assets/js/utils/follow_action.js new file mode 100644 index 000000000..3a911b77d --- /dev/null +++ b/funnel/assets/js/utils/follow_action.js @@ -0,0 +1,46 @@ +import toastr from 'toastr'; +import Form from './formhelper'; + +const handleFollow = (thisForm) => { + const accountID = $(thisForm).data('account-id'); + const $accountFollowForms = $(`form[data-account-id=${accountID}]`); + + $accountFollowForms.find('button').prop('disabled', false); + const formId = $(thisForm).attr('id'); + const postUrl = $(thisForm).attr('action'); + + const config = {}; + config.formData = new URLSearchParams({ + follow: $(thisForm).find('input[name="follow"]').val(), + csrf_token: $('meta[name="csrf-token"]').attr('content'), + }).toString(); + + const onSuccess = (response) => { + $accountFollowForms.find('button').prop('disabled', false); + + if (response.following) { + $accountFollowForms.find('button.js-unfollow-btn').removeClass('mui--hide'); + $accountFollowForms.find('button.js-follow-btn').addClass('mui--hide'); + toastr.success(window.gettext('Your are now following this account')); + } else { + $accountFollowForms.find('button.js-follow-btn').removeClass('mui--hide'); + $accountFollowForms.find('button.js-unfollow-btn').addClass('mui--hide'); + toastr.success(window.gettext('You have unfollowed this account')); + } + }; + + const onError = (error) => { + $accountFollowForms.find('button').prop('disabled', false); + const errorMsg = Form.handleAjaxError(error); + toastr.error(errorMsg); + }; + + Form.ajaxFormSubmit(formId, postUrl, onSuccess, onError, config); +}; + +$(() => { + $('html.userlogin body').on('click', '.js-follow-form', function follow(event) { + event.preventDefault(); + handleFollow(this); + }); +}); diff --git a/funnel/assets/js/utils/formhelper.js b/funnel/assets/js/utils/formhelper.js index 9c957785d..c13845c97 100644 --- a/funnel/assets/js/utils/formhelper.js +++ b/funnel/assets/js/utils/formhelper.js @@ -50,7 +50,6 @@ const Form = { return errorMsg; }, handleAjaxError(errorResponse) { - Form.updateFormNonce(errorResponse.responseJSON); const errorMsg = Form.getResponseError(errorResponse); return errorMsg; }, @@ -62,11 +61,6 @@ const Form = { getActionUrl(formId) { return $(`#${formId}`).attr('action'); }, - updateFormNonce(response) { - if (response && response.form_nonce) { - $('input[name="form_nonce"]').val(response.form_nonce); - } - }, preventSubmitOnEnter(id) { $(`#${id}`).on('keyup keypress', (e) => { const code = e.keyCode || e.which; diff --git a/funnel/assets/js/utils/helper.js b/funnel/assets/js/utils/helper.js index 66f4f0f4b..1af05e0fa 100644 --- a/funnel/assets/js/utils/helper.js +++ b/funnel/assets/js/utils/helper.js @@ -230,8 +230,7 @@ const Utils = { }); }, copyToClipboard(elem) { - const textElem = document.querySelector(elem); - const stringToCopy = textElem.innerHTML; + const stringToCopy = elem.innerHTML; if (navigator.clipboard) { navigator.clipboard.writeText(stringToCopy).then( () => toastr.success(window.gettext('Link copied')), @@ -240,7 +239,7 @@ const Utils = { } else { const selection = window.getSelection(); const range = document.createRange(); - range.selectNodeContents(textElem); + range.selectNodeContents(elem); selection.removeAllRanges(); selection.addRange(range); if (document.execCommand('copy')) { diff --git a/funnel/assets/js/utils/jsonform.js b/funnel/assets/js/utils/jsonform.js index c80805d83..4a703d4e1 100644 --- a/funnel/assets/js/utils/jsonform.js +++ b/funnel/assets/js/utils/jsonform.js @@ -9,8 +9,7 @@ const jsonForm = Vue.component('jsonform', { const obj = {}; const formData = $(`#${this.formid}`).serializeArray(); formData.forEach((field) => { - if (field.name !== 'form_nonce' && field.name !== 'csrf_token') - obj[field.name] = field.value; + if (field.name !== 'csrf_token') obj[field.name] = field.value; }); return JSON.stringify(obj); }, @@ -32,7 +31,6 @@ const jsonForm = Vue.component('jsonform', { contentType: 'application/json', dataType: 'html', formData: JSON.stringify({ - form_nonce: formValues.get('form_nonce'), csrf_token: formValues.get('csrf_token'), form: form.getFormData(), }), diff --git a/funnel/assets/js/utils/ticket_widget.js b/funnel/assets/js/utils/ticket_widget.js index f791cd843..5a45fff5f 100644 --- a/funnel/assets/js/utils/ticket_widget.js +++ b/funnel/assets/js/utils/ticket_widget.js @@ -14,8 +14,11 @@ const Ticketing = { boxofficeUrl, widgetElem, org, - itemCollectionId, - itemCollectionTitle, + menuId, + menuTitle, + userName, + userEmail, + userPhone, }) { let url; @@ -72,8 +75,11 @@ const Ticketing = { () => { window.Boxoffice.init({ org, - itemCollection: itemCollectionId, - paymentDesc: itemCollectionTitle, + menu: menuId, + paymentDesc: menuTitle, + user_name: userName, + user_email: userEmail, + user_phone: userPhone, }); }, false diff --git a/funnel/assets/js/utils/webshare.js b/funnel/assets/js/utils/webshare.js index 54625b114..3a9b6a868 100644 --- a/funnel/assets/js/utils/webshare.js +++ b/funnel/assets/js/utils/webshare.js @@ -43,13 +43,13 @@ const WebShare = { event.preventDefault(); const linkElem = this; if ($(linkElem).attr('data-shortlink')) { - Utils.copyToClipboard('.js-copy-url'); + Utils.copyToClipboard($(linkElem).find('.js-copy-url')[0]); } else { Utils.fetchShortUrl($(linkElem).find('.js-copy-url').first().html()) .then((shortlink) => { $(linkElem).find('.js-copy-url').text(shortlink); $(linkElem).attr('data-shortlink', true); - Utils.copyToClipboard('.js-copy-url'); + Utils.copyToClipboard($(linkElem).find('.js-copy-url')[0]); }) .catch((errMsg) => { toastr.error(errMsg); diff --git a/funnel/assets/sass/app.scss b/funnel/assets/sass/app.scss index d576e4a32..4896d087b 100644 --- a/funnel/assets/sass/app.scss +++ b/funnel/assets/sass/app.scss @@ -25,6 +25,7 @@ @import 'components/menu'; @import 'components/search-field'; @import 'components/chip'; +@import 'components/badge'; @import 'components/alert'; @import 'components/thumbnail'; @import 'components/tabs'; diff --git a/funnel/assets/sass/base/_utils.scss b/funnel/assets/sass/base/_utils.scss index 3f9d64542..3de432806 100644 --- a/funnel/assets/sass/base/_utils.scss +++ b/funnel/assets/sass/base/_utils.scss @@ -10,6 +10,14 @@ margin-top: 0 !important; } +.zero-left-margin { + margin-left: 0 !important; +} + +.zero-right-margin { + margin-right: 0 !important; +} + .margin-bottom { margin-bottom: $mui-grid-padding * 0.5; } @@ -100,6 +108,7 @@ .flex-wrapper--end { align-items: flex-end; + margin-left: auto !important; } .flex-wrapper--space-between { @@ -134,6 +143,19 @@ flex-grow: 1; } +.flex-item-align-end { + align-self: flex-end; + margin-left: auto !important; +} + +.flex-order-last { + order: 999; +} + +.flex-wrapper-full-width { + width: 100%; +} + // ============================================================================ // DISPLAY // ============================================================================ diff --git a/funnel/assets/sass/components/_badge.scss b/funnel/assets/sass/components/_badge.scss new file mode 100644 index 000000000..43364c4c1 --- /dev/null +++ b/funnel/assets/sass/components/_badge.scss @@ -0,0 +1,69 @@ +.badge { + position: relative; + color: $mui-text-hyperlink; + min-width: $mui-grid-padding; + line-height: $mui-grid-padding; + min-height: $mui-grid-padding; + text-align: center; + display: inline-block; + background-color: transparentize($mui-primary-color, 0.85); + border-radius: 2px; + padding: 0 $mui-grid-padding * 0.5; + font-size: 10px; + text-transform: uppercase; + font-weight: 600; +} + +.badge--round { + border-radius: 50%; + width: 25px; + line-height: 25px; + padding: 0; +} + +.badge--round-smaller { + width: 16px; + line-height: 16px; +} + +.badge--tab { + margin-right: $mui-grid-padding * 0.25; + margin-left: $mui-grid-padding * 0.25; + border-radius: 16px; + padding: 0 4px; + width: auto; + line-height: inherit; + background: $mui-bg-color-primary; + border: 1px solid $mui-bg-color-dark; + background: $mui-bg-color-primary; +} + +.badge-accent { + background-color: $mui-bg-color-accent; + color: $mui-text-dark; +} + +.badge-light { + background-color: $mui-bg-color-dark; + color: $mui-text-dark; +} + +.badge-dark { + background-color: $mui-bg-color-dark; + color: $mui-text-dark; +} + +.badge-danger { + background-color: $mui-danger-color; + color: mui-color('white'); +} + +.badge-success { + background-color: $mui-success-color; + border-color: transparentize($mui-text-success, 0.8); + color: $mui-text-success; +} + +.badge + .badge { + margin-left: $mui-btn-spacing-horizontal; +} diff --git a/funnel/assets/sass/components/_button.scss b/funnel/assets/sass/components/_button.scss index 064ff09bd..ed82afbf9 100644 --- a/funnel/assets/sass/components/_button.scss +++ b/funnel/assets/sass/components/_button.scss @@ -94,6 +94,19 @@ box-shadow: none; } +.mui-btn--danger { + background: $mui-bg-color-primary; + color: $mui-text-danger; + border: 1px solid $mui-text-danger; +} + +.mui-btn__icon { + display: flex; + flex-direction: row; + gap: $mui-grid-padding/2; + align-items: center; +} + .mui-btn--accent.mui--is-disabled, .mui-btn--accent.mui--is-disabled:hover, .mui-btn--accent.mui--is-disabled:active, diff --git a/funnel/assets/sass/components/_card.scss b/funnel/assets/sass/components/_card.scss index 879bf8f80..9f8c7ebef 100644 --- a/funnel/assets/sass/components/_card.scss +++ b/funnel/assets/sass/components/_card.scss @@ -43,6 +43,8 @@ .mui-btn + .mui-btn { margin-left: 0; + padding: 2px 10px; + border-radius: 4px; } } @@ -124,6 +126,7 @@ margin: 0; width: 100%; display: none; + font-weight: 600; justify-content: space-between; .calendar__month__counting { @@ -185,6 +188,10 @@ } } + .calendar__weekdays__dates__date--flex { + width: auto !important; + } + .calendar__weekdays__dates__date--today { .calendar__weekdays__dates__date__day { color: $mui-text-dark; @@ -220,6 +227,9 @@ z-index: 1; border: 1px solid $mui-primary-color-lighter; } + .calendar__weekdays__dates__date--flex:after { + margin: 0 -8px !important; + } .calendar__weekdays__dates__date--active:before { content: ''; border-left: 5px solid transparent; @@ -295,6 +305,10 @@ .calendar__weekdays__dates--latest { display: flex; } + + .calendar__weekdays__dates--justify { + justify-content: space-between; + } } } } diff --git a/funnel/assets/sass/components/_list.scss b/funnel/assets/sass/components/_list.scss index 58aabc962..b9e1ef2b7 100644 --- a/funnel/assets/sass/components/_list.scss +++ b/funnel/assets/sass/components/_list.scss @@ -67,10 +67,10 @@ .bullet-separated-list--flex { padding-left: 0; display: flex; - list-style: inside; + list-style-type: none; margin: 0; li { - padding: 0 3px; + padding: 0 8px 0 0; span.bullet-separated-list__span { position: relative; left: -7px; @@ -78,7 +78,13 @@ } li:first-child { list-style-type: none; - padding-left: 0; + padding: 0 20px 0 0; + } + li:nth-child(2) { + list-style-type: disc; + } + li:last-child { + padding: 0; } } @@ -86,13 +92,17 @@ .bullet-separated-list { padding-left: 0; display: flex; - list-style: inside; + list-style-type: disc; li { - padding: 0 8px; + padding: 0 20px 0 0; } li:first-child { + margin-left: -8px; list-style-type: none; padding-left: 0; } + li:last-child { + padding: 0; + } } } diff --git a/funnel/assets/sass/components/_subnavbar.scss b/funnel/assets/sass/components/_subnavbar.scss index 4f221e422..2003420cb 100644 --- a/funnel/assets/sass/components/_subnavbar.scss +++ b/funnel/assets/sass/components/_subnavbar.scss @@ -1,5 +1,6 @@ .sub-navbar-container--sticky { margin-top: $mui-grid-padding; + z-index: 3; } .sub-navbar { diff --git a/funnel/assets/sass/components/_tabs.scss b/funnel/assets/sass/components/_tabs.scss index 82aa73c03..e680ad1c5 100644 --- a/funnel/assets/sass/components/_tabs.scss +++ b/funnel/assets/sass/components/_tabs.scss @@ -246,6 +246,11 @@ border: 1px solid transparentize($mui-primary-color, 0.8); } + .tabs__item--active .badge--tab { + border: 1px solid transparent; + background: transparentize($mui-primary-color, 0.85); + } + .tabs__item-control { position: fixed; z-index: 1000; @@ -273,52 +278,6 @@ display: none; } -.badge { - position: relative; - color: $mui-text-hyperlink; - min-width: $mui-grid-padding; - line-height: $mui-grid-padding; - min-height: $mui-grid-padding; - text-align: center; - display: inline-block; - background-color: transparentize($mui-primary-color, 0.85); - border-radius: 2px; - padding: 0 $mui-grid-padding * 0.5; - font-size: 10px; - text-transform: uppercase; - font-weight: 600; -} - -.badge--round { - border-radius: 50%; - width: 25px; - line-height: 25px; - padding: 0; -} - -.badge--round-smaller { - width: 16px; - line-height: 16px; -} - -.badge--tab { - margin-right: $mui-grid-padding * 0.25; - margin-left: $mui-grid-padding * 0.25; - border-radius: 16px; - padding: 0 4px; - width: auto; - line-height: inherit; - background: $mui-bg-color-primary; - border: 1px solid $mui-bg-color-dark; - background: $mui-bg-color-primary; -} - -.tabs__item--active .badge--tab, -.badge--primary { - border: 1px solid transparent; - background: transparentize($mui-primary-color, 0.85); -} - .md-tabset { ul[role='tablist'] { @extend .mui-tabs__bar; diff --git a/funnel/assets/sass/components/_ticket-modal.scss b/funnel/assets/sass/components/_ticket-modal.scss index c3253e56f..70330ba69 100644 --- a/funnel/assets/sass/components/_ticket-modal.scss +++ b/funnel/assets/sass/components/_ticket-modal.scss @@ -70,7 +70,7 @@ .price-btn { min-width: 150px; font-size: inherit; - padding: 0; + padding: 0 8px; display: flex; flex-direction: column; align-items: center; diff --git a/funnel/assets/sass/form.scss b/funnel/assets/sass/form.scss index fc5b85a84..32eedcb79 100644 --- a/funnel/assets/sass/form.scss +++ b/funnel/assets/sass/form.scss @@ -186,6 +186,8 @@ } .mui-select__menu { z-index: 100; + width: 100%; + top: 0 !important; } } diff --git a/funnel/assets/sass/mui/_dropdown.scss b/funnel/assets/sass/mui/_dropdown.scss index dd0d54773..16a135b0e 100644 --- a/funnel/assets/sass/mui/_dropdown.scss +++ b/funnel/assets/sass/mui/_dropdown.scss @@ -45,7 +45,7 @@ text-align: left; background-color: $mui-dropdown-bg-color; border-radius: $mui-dropdown-border-radius; - z-index: 1; + z-index: 4; background-clip: padding-box; // open state diff --git a/funnel/assets/sass/pages/index.scss b/funnel/assets/sass/pages/index.scss index 522672d30..f7cae2891 100644 --- a/funnel/assets/sass/pages/index.scss +++ b/funnel/assets/sass/pages/index.scss @@ -48,6 +48,11 @@ .spotlight-container__details { margin-top: 40px; } + .mui-btn + .mui-btn { + margin-left: 0; + padding: 2px 10px; + border-radius: 4px; + } } } diff --git a/funnel/assets/sass/pages/profile.scss b/funnel/assets/sass/pages/profile.scss index bf489b456..92a27fc31 100644 --- a/funnel/assets/sass/pages/profile.scss +++ b/funnel/assets/sass/pages/profile.scss @@ -99,6 +99,15 @@ } } +.profile.profile--unverified { + .profile__banner__box { + padding-bottom: 70px; + .profile__banner__box__img { + display: none; + } + } +} + .profile-create-btn { margin-right: 8px; margin-top: 0; @@ -108,6 +117,10 @@ width: 100%; .mui-btn { width: 100%; + margin-left: 0; + } + .admin-btns { + display: inline-block; } } @@ -181,13 +194,31 @@ position: absolute; bottom: -58px; z-index: 1000; - right: $mui-grid-padding; + right: 0; + display: flex; + align-items: center; + .admin-btns { + display: inline-block; + margin-left: $mui-grid-padding * 0.5; + } + } + .profile__btns { + display: flex; + .mui-btn { + width: auto; + margin-left: $mui-grid-padding * 0.5; + } } } } .profile-details { padding: 2 * $mui-grid-padding 0 0; } + .profile.profile--unverified { + .profile__banner__box { + padding-bottom: 45px; + } + } } .profile-subheader { diff --git a/funnel/assets/sass/pages/profile_calendar.scss b/funnel/assets/sass/pages/profile_calendar.scss new file mode 100644 index 000000000..b836c9c2a --- /dev/null +++ b/funnel/assets/sass/pages/profile_calendar.scss @@ -0,0 +1,177 @@ +@use 'sass:math'; +@import '../base/variable'; + +.calendar-container { + width: 100%; + min-height: 300px; +} + +.fc-header-toolbar { + display: none !important; +} + +.fc { + margin: $mui-grid-padding auto; + width: 100%; + max-width: 100%; + + table.fc-scrollgrid-sync-table { + height: 200px !important; + } + + thead { + border-bottom: 8px solid #fff; + } + + th { + color: #4d5763; + font-variant: all-small-caps; + font-weight: 600; + font-size: 12px; + line-height: 14px; + opacity: 0.5; + } + + .fc-col-header-cell-cushion { + color: $mui-text-dark; + } + + .fc-daygrid-day-number { + margin: auto; + color: $mui-text-light; + } + + .fc-daygrid-dot-event { + justify-content: center; + margin: 0 !important; + padding: 0; + } + + .fc-day-disabled { + background-color: #fff; + } + + .fc-multimonth-month { + justify-content: left; + padding: 0; + background: #fff; + } + + --fc-border-color: #fff; + --fc-today-bg-color: #e1eefd; +} + +.fc-event-time, +.fc-event-title { + display: none; +} + +.fc-popover-body .fc-event-time, +.fc-popover-body .fc-event-title { + display: block; +} + +table.proposal-list-table tr { + border-bottom: 1px solid rgba(132, 146, 166, 0.3); +} + +.flex-wrapper--baseline { + justify-content: right; + padding: math.div($mui-grid-padding, 2) $mui-grid-padding 0 0; + position: relative; + z-index: 3; +} + +.fc-text-caption { + font-size: 14px; + font-variant: all-small-caps; +} + +.fc-multimonth { + margin: -66px 0 0; +} + +.fc-multimonth-title { + font-size: 14px !important; + font-variant: all-small-caps; + padding: $mui-grid-padding !important; + text-align: left !important; + font-weight: 600 !important; +} + +.mui-table { + border-collapse: collapse; + overflow: hidden; + margin: 0 !important; + border-radius: 16px; +} +.fc-view-harness { + height: 270px !important; +} + +.fc-daygrid-day-frame { + height: 38px; +} + +.fc-day-past, +.fc-day-future, +.fc-day-today { + color: #4d5763; + font-weight: 600; + font-size: 14px; + line-height: 16px; + opacity: 06; + background: #fff !important; +} + +.fc-day-past .fc-daygrid-day-number { + opacity: 0.5; +} +.fc-day-future .fc-daygrid-day-number { + opacity: 0.5; +} +.fc-day-today .fc-daygrid-day-number { + opacity: 1; + color: #1f2d3d; + font-weight: 700; +} + +@media (max-width: 768px) { + .calendar_mobile .grid__col-12 { + order: 2; + } + .calendar_mobile .proposal_list { + order: 2; + } + .calendar_mobile .calendar_mobile_container { + order: 1; + margin: -$mui-grid-padding-double 0 $mui-grid-padding * 1.5; + position: sticky; + top: $mui-grid-padding-double; + z-index: 2; + } +} + +.proposal_venue { + margin: $mui-grid-padding 0 0; + display: flex; + gap: math.div($mui-grid-padding, 4); +} + +.filter-menu { + position: absolute; + left: 158px; + padding: $mui-grid-padding; + background-color: $mui-bg-color-primary; + z-index: 1000; + box-shadow: + 0 0 2px rgba(0, 0, 0, 0.12), + 0 2px 2px rgba(0, 0, 0, 0.2); + border-radius: 0 16px 16px 16px; + min-width: 200px; + + button { + width: 100%; + margin-top: $mui-grid-padding; + } +} diff --git a/funnel/assets/sass/pages/project.scss b/funnel/assets/sass/pages/project.scss index ed55ef20c..e7e8b7569 100644 --- a/funnel/assets/sass/pages/project.scss +++ b/funnel/assets/sass/pages/project.scss @@ -63,11 +63,13 @@ display: none; } .register-block__content__txt { - font-size: 9px; + font-size: 12px; line-height: 16px; + letter-spacing: -0.07rem; width: 100%; display: block; font-style: italic; + font-weight: 600; margin-bottom: $mui-grid-padding * 0.25; } .register-block__btn { @@ -92,6 +94,9 @@ &:hover { border-color: $mui-text-danger; } + .register-block__btn__member-txt { + font-size: 12px; + } } .register-block__btn.mui--is-disabled:hover { border-color: inherit; @@ -101,8 +106,12 @@ width: calc(50% - $mui-grid-padding/2); align-self: flex-end; } + .register-block__btn--full-width { + width: 100% !important; + margin-top: $mui-grid-padding; + } .register-block__content:only-child { - width: 100%; + width: 100% !important; align-self: flex-end; } } @@ -111,7 +120,7 @@ .register-block { .register-block__content { .register-block__content__txt { - font-size: 10px; + font-size: 12px; } } } @@ -152,18 +161,20 @@ @media (min-width: 768px) { .project-footer { + padding: 16px 0 0 !important; .register-block { display: block; .register-block__content { - width: 100%; + width: calc(50% - $mui-grid-padding/2); margin-right: 0; margin-bottom: $mui-grid-padding; .register-block__content__rsvp-txt { font-size: 12px; } .register-block__content__txt { - font-size: 14px; - line-height: 21px; + font-size: 12px; + line-height: 16px; + letter-spacing: -0.01rem; margin-bottom: $mui-grid-padding * 0.5; } .register-block__btn { @@ -179,6 +190,11 @@ } } } + .flex-content { + display: flex; + flex-direction: row; + justify-content: space-between; + } } } @@ -223,6 +239,14 @@ } } +.follow-form { + .mui-btn--accent { + margin-left: 0; + padding: 2px 10px; + border-radius: 4px; + } +} + @media (min-width: 768px) { .project-header { position: relative; @@ -283,6 +307,12 @@ .project-banner__profile-details__badge { margin-left: auto; } + .mui-btn + .mui-btn, + .mui-btn--accent { + margin-left: 0; + padding: 2px 10px; + border-radius: 4px; + } } .project-banner__profile-details--center { @@ -356,6 +386,7 @@ .calendar__weekdays .calendar__weekdays__dates--latest { display: flex; margin-bottom: $mui-grid-padding * 0.5; + font-weight: 600; } .calendar__weekdays .calendar__weekdays__dates:last-child { @@ -377,6 +408,9 @@ } } } + .project-banner__right--flex-direction { + flex-direction: column; + } } .project-banner__left, @@ -419,13 +453,13 @@ } .project-banner__left { width: 67%; - margin: 0 $mui-grid-padding 0 0; + margin: 0 $mui-grid-padding * 2 0 0; .embed-video-wrapper--shaped { border-radius: 16px; } } .project-banner__right { - width: 33%; + width: auto; } } .project-banner.project-banner--inner { @@ -433,7 +467,7 @@ width: 25%; } .project-banner__right--smaller { - width: 75%; + width: auto; } } } @@ -543,10 +577,9 @@ } .project-section__map { - height: 400px; + height: 300px; width: 100%; z-index: 0; - margin: 0 0 $mui-grid-padding; position: relative; outline: none; } diff --git a/funnel/assets/sass/pages/schedule.scss b/funnel/assets/sass/pages/schedule.scss index 6ce6869fb..d485711de 100644 --- a/funnel/assets/sass/pages/schedule.scss +++ b/funnel/assets/sass/pages/schedule.scss @@ -266,6 +266,10 @@ } } } + .schedule-grid.schedule-grid--compressed .schedule .schedule__table { + max-height: 50vh; + overflow: scroll; + } } .calendar-wrapper { diff --git a/funnel/assets/sass/pages/update.scss b/funnel/assets/sass/pages/update.scss index 3ebcb3a86..451a3b630 100644 --- a/funnel/assets/sass/pages/update.scss +++ b/funnel/assets/sass/pages/update.scss @@ -37,6 +37,7 @@ position: relative; .update__heading { + display: flex; .update__heading__unpin { display: none; } diff --git a/funnel/cli/__init__.py b/funnel/cli/__init__.py index 436de6da5..38ee86439 100644 --- a/funnel/cli/__init__.py +++ b/funnel/cli/__init__.py @@ -1,7 +1,5 @@ """Command line interface.""" -# flake8: noqa +from . import geodata, lint, misc, periodic, refresh -from __future__ import annotations - -from . import geodata, misc, periodic, refresh +__all__ = ['geodata', 'lint', 'misc', 'periodic', 'refresh'] diff --git a/funnel/cli/geodata.py b/funnel/cli/geodata.py index 839d29b1b..7934b4d96 100644 --- a/funnel/cli/geodata.py +++ b/funnel/cli/geodata.py @@ -10,6 +10,7 @@ from dataclasses import dataclass from datetime import datetime from decimal import Decimal +from http import HTTPStatus from urllib.parse import urljoin import click @@ -30,6 +31,9 @@ db, ) +ONE_DAY = 86400 +POPULATION_THRESHOLD = 15000 + csv.field_size_limit(sys.maxsize) geo = AppGroup('geonames', help="Process geonames data.") @@ -99,7 +103,7 @@ class GeoAdminRecord: class GeoAltNameRecord: """Geonames alt name record.""" - id: str # noqa: A003 + id: str geonameid: str lang: str title: str @@ -111,13 +115,10 @@ class GeoAltNameRecord: def downloadfile(basepath: str, filename: str, folder: str | None = None) -> None: """Download a geoname record file.""" - if not folder: - folder_file = filename - else: - folder_file = os.path.join(folder, filename) + folder_file = filename if not folder else os.path.join(folder, filename) if ( os.path.exists(folder_file) - and (time.time() - os.path.getmtime(folder_file)) < 86400 + and (time.time() - os.path.getmtime(folder_file)) < ONE_DAY ): click.echo(f"Skipping re-download of recent {filename}") return @@ -132,7 +133,7 @@ def downloadfile(basepath: str, filename: str, folder: str | None = None) -> Non task = progress.add_task(f"Downloading {filename}", total=None) url = urljoin(basepath, filename) r = requests.get(url, stream=True, timeout=30) - if r.status_code == 200: + if r.status_code == HTTPStatus.OK: filesize = int(r.headers.get('content-length', 0)) if filesize: progress.update(task, total=filesize) @@ -246,7 +247,7 @@ def load_geonames(filename: str) -> None: ( rec.population.isdigit() and int(rec.population != 0) - and int(rec.population) < 15000 + and int(rec.population) < POPULATION_THRESHOLD ) or not rec.population.isdigit() ): diff --git a/funnel/cli/lint/__init__.py b/funnel/cli/lint/__init__.py new file mode 100644 index 000000000..655d4caad --- /dev/null +++ b/funnel/cli/lint/__init__.py @@ -0,0 +1,13 @@ +"""Linter commands.""" + +from flask.cli import AppGroup + +from ... import app + +lint = AppGroup('lint', help="Periodic tasks from cron (with recommended intervals)") + +from . import jinja + +app.cli.add_command(lint) + +__all__ = ['lint', 'jinja'] diff --git a/funnel/cli/lint/jinja.py b/funnel/cli/lint/jinja.py new file mode 100644 index 000000000..5c75c212a --- /dev/null +++ b/funnel/cli/lint/jinja.py @@ -0,0 +1,106 @@ +"""Jinja template linter.""" + +import sys +from collections import deque +from collections.abc import Sequence + +import click +import jinja2 +from jinja2 import TemplateNotFound, TemplateSyntaxError +from rich.console import Console + +from ... import app +from ...utils import JinjaTemplateBase +from . import lint + + +def all_jinja_template_classes() -> dict[str, type[JinjaTemplateBase]]: + """Find all JinjaTemplate classes and return as a dict against the template name.""" + classes = deque([JinjaTemplateBase]) + result: dict[str, type[JinjaTemplateBase]] = {} + while True: + try: + cls = classes.popleft() + except IndexError: + break + if hasattr(cls, '_template'): + result[cls._template] = cls # pylint: disable=protected-access + classes.extend(cls.__subclasses__()) + return result + + +@lint.command('jinja') +@click.argument('templates', nargs=-1) +@click.option('-a', '--all', is_flag=True, help="Lint all Jinja templates.") +def lint_jinja_templates( + templates: Sequence[str], + all: bool = False, # noqa: A002 # pylint: disable=redefined-builtin +) -> None: + """Lint Jinja templates.""" + if all: + templates = list(templates) + app.jinja_env.list_templates() + elif not templates: + click.echo("Specify template names (not paths) or --all") + + template_classes = all_jinja_template_classes() + console = Console(highlight=False) + rprint = console.print + + for template in templates: + has_cls = True + cls = template_classes.get(template) + if cls is None: + has_cls = False + cls = type('_', (JinjaTemplateBase,), {}, template=template) + try: + report = cls.jinja_unresolved_identifiers() + except TemplateNotFound: + rprint(f"[red][bold]{template}[/] not found :cross_mark:") + continue + except TemplateSyntaxError: + rprint(f"[red][bold]{template}[/] syntax error") + console.print_exception( + width=None, + max_frames=1, + word_wrap=True, + suppress=['funnel/cli', 'funnel/utils', jinja2], + ) + continue + has_deps = len(report) > 1 + has_undefined = any(report.values()) + if not has_cls: + rprint(f"[bold white]{template}[/]", end='') + else: + rprint( + f"[bold white]{template}[/]" + f" ([link=file://{sys.modules[cls.__module__].__file__}]" + f"{cls.__module__}.[bold]{cls.__qualname__}[/][/])", + end='', + ) + if has_undefined: + if has_cls: + rprint(' :cross_mark_button:', end='') + else: + rprint(' :cross_mark:', end='') + else: + rprint(' :white_check_mark:', end='') + if has_deps and has_undefined: + rprint(" [dim]dependencies and undefined names:") + elif has_deps: + rprint(" [dim]dependencies:") + elif has_undefined: + rprint(" [dim]undefined names:") + else: + rprint() + if has_deps or has_undefined: + for dep_template, undefined_names in report.items(): + rprint(f" - {dep_template}", end='') + if undefined_names: + rprint(': ', end='') + rprint( + ', '.join( + f'[red]{line}:[bold]{name}[/][/]' + for line, name in sorted(undefined_names) + ), + soft_wrap=True, + ) diff --git a/funnel/cli/misc.py b/funnel/cli/misc.py index 2c02c7ed0..4c7f78cb9 100644 --- a/funnel/cli/misc.py +++ b/funnel/cli/misc.py @@ -46,7 +46,7 @@ def dbcreate() -> None: @app.cli.command('baseframe_translations_path') def baseframe_translations_path() -> None: """Show path to Baseframe translations.""" - click.echo(list(baseframe_translations.translation_directories)[0]) + click.echo(next(iter(baseframe_translations.translation_directories))) @app.cli.command('checkenv') diff --git a/funnel/cli/periodic/__init__.py b/funnel/cli/periodic/__init__.py index 7d0e34674..767ea880e 100644 --- a/funnel/cli/periodic/__init__.py +++ b/funnel/cli/periodic/__init__.py @@ -8,6 +8,8 @@ 'periodic', help="Periodic tasks from cron (with recommended intervals)" ) -from . import mnrl, notification, stats # noqa: F401 +from . import mnrl, notification, stats app.cli.add_command(periodic) + +__all__ = ['periodic', 'mnrl', 'notification', 'stats'] diff --git a/funnel/cli/periodic/mnrl.py b/funnel/cli/periodic/mnrl.py index 32ed7289f..edede8005 100644 --- a/funnel/cli/periodic/mnrl.py +++ b/funnel/cli/periodic/mnrl.py @@ -35,8 +35,10 @@ - GET - Can be used to download the file. (xlsx, pdf, json, rar) """ +# spell-checker:ignore TRAI ijson HTTPX apikey anext import asyncio +from http import HTTPStatus import click import httpx @@ -99,17 +101,18 @@ async def get_mnrl_json_file_list(apikey: str) -> list[str]: response = await httpx.AsyncClient(http2=True).get( f'https://mnrl.trai.gov.in/api/mnrl/files/{apikey}', timeout=300 ) - if response.status_code == 401: + # MNRL makes non-standard use of HTTP 407 to indicate API key expiry + if response.status_code == HTTPStatus.UNAUTHORIZED: raise KeyInvalidError() - if response.status_code == 407: + if response.status_code == HTTPStatus.PROXY_AUTHENTICATION_REQUIRED: raise KeyExpiredError() response.raise_for_status() result = response.json() # Fallback tests for non-200 status codes in a 200 response (current API behaviour) - if result['status'] == 401: + if result['status'] == HTTPStatus.UNAUTHORIZED: raise KeyInvalidError() - if result['status'] == 407: + if result['status'] == HTTPStatus.PROXY_AUTHENTICATION_REQUIRED: raise KeyExpiredError() return [row['file_name'] for row in result['mnrl_files']['json']] @@ -139,20 +142,20 @@ async def forget_phone_numbers(phone_numbers: set[str], prefix: str) -> None: """Mark phone numbers as forgotten.""" for unprefixed in phone_numbers: number = prefix + unprefixed - userphone = AccountPhone.get(number) - if userphone is not None: - # TODO: Dispatch a notification to userphone.account, but since the + account_phone = AccountPhone.get(number) + if account_phone is not None: + # TODO: Dispatch a notification to account_phone.account, but since the # notification will not know the phone number (it'll already be forgotten), # we need a new db model to contain custom messages # TODO: Also delay dispatch until the full MNRL scan is complete -- their # backup contact phone number may also have expired. That means this # function will create notifications and return them, leaving dispatch to # the outermost function - rprint(f"{userphone} - owned by {userphone.account.pickername}") + rprint(f"{account_phone} - owned by {account_phone.account.pickername}") # TODO: MNRL isn't foolproof. Don't delete! Instead, notify the user and # only delete if they don't respond (How? Maybe delete and send them a # re-add token?) - # db.session.delete(userphone) + # db.session.delete(account_phone) phone_number = PhoneNumber.get(number) if phone_number is not None: rprint( @@ -234,7 +237,7 @@ async def process_mnrl_files( try: # TODO: Change this to `notifications = await task` then return them too await task - except Exception as exc: # noqa: B902 # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except app.logger.exception("%s in forget_phone_numbers", repr(exc)) return revoked_phone_numbers, mnrl_total_count, failures @@ -254,7 +257,7 @@ async def process_mnrl(apikey: str) -> None: except httpx.HTTPError as exc: err = f"{exc!r} in MNRL API getting download list" rprint(f"[red]{err}") - raise click.ClickException(err) + raise click.ClickException(err) from exc revoked_phone_numbers, mnrl_total_count, failures = await process_mnrl_files( apikey, existing_phone_numbers, phone_prefix, mnrl_filenames diff --git a/funnel/cli/periodic/notification.py b/funnel/cli/periodic/notification.py index c5454c7c7..a2045c59a 100644 --- a/funnel/cli/periodic/notification.py +++ b/funnel/cli/periodic/notification.py @@ -12,7 +12,7 @@ @periodic.command('project_starting_alert') def project_starting_alert() -> None: - """Send notifications for projects that are about to start schedule (5m).""" + """Send alerts for sessions that are about to start (5m).""" # Rollback to the most recent 5 minute interval, to account for startup delay # for periodic job processes. use_now = db.session.query( @@ -25,10 +25,6 @@ def project_starting_alert() -> None: # Find all projects that have a session starting between 10 and 15 minutes from # use_now, and where the same project did not have a session ending within # the prior hour. - - # Any eager-loading columns and relationships should be deferred with - # sa_orm.defer(column) and sa_orm.noload(relationship). There are none as of this - # commit. for project in ( models.Project.starting_at( use_now + timedelta(minutes=10), @@ -44,3 +40,23 @@ def project_starting_alert() -> None: fragment=project.next_session_from(use_now + timedelta(minutes=10)), ) ) + + # Find all projects with a venue that have a session starting 24 hours from now + for project in ( + models.Project.starting_at( + use_now + timedelta(hours=24), timedelta(minutes=10), timedelta(minutes=60) + ) + .filter( + models.Venue.query.filter( + models.Venue.project_id == models.Project.id + ).exists() + ) + .options(sa_orm.load_only(models.Project.uuid)) + .all() + ): + dispatch_notification( + models.ProjectTomorrowNotification( + document=project, + fragment=project.next_session_from(use_now + timedelta(hours=24)), + ) + ) diff --git a/funnel/cli/periodic/stats.py b/funnel/cli/periodic/stats.py index 03ddceac1..49b8b9c93 100644 --- a/funnel/cli/periodic/stats.py +++ b/funnel/cli/periodic/stats.py @@ -23,7 +23,7 @@ from ...models import Mapped, Query, db, sa from . import periodic -# --- Data structures ------------------------------------------------------------------ +# MARK: Data structures ---------------------------------------------------------------- def trend_symbol(current: int, previous: int) -> str: @@ -114,7 +114,7 @@ class MatomoData: visits_month: MatomoResponse | None = None -# --- Matomo analytics ----------------------------------------------------------------- +# MARK: Matomo analytics --------------------------------------------------------------- @overload @@ -242,7 +242,7 @@ async def matomo_stats() -> MatomoData: ) -# --- Internal database analytics ------------------------------------------------------ +# MARK: Internal database analytics ---------------------------------------------------- def data_sources() -> dict[str, DataSource]: @@ -365,7 +365,7 @@ async def user_stats() -> dict[str, ResourceStats]: return stats -# --- Commands ------------------------------------------------------------------------- +# MARK: Commands ----------------------------------------------------------------------- async def dailystats() -> None: diff --git a/funnel/cli/refresh/__init__.py b/funnel/cli/refresh/__init__.py index b16825cc2..3221d93d1 100644 --- a/funnel/cli/refresh/__init__.py +++ b/funnel/cli/refresh/__init__.py @@ -6,6 +6,8 @@ refresh = AppGroup('refresh', help="Refresh or purge caches") -from . import markdown # isort:skip # noqa: F401 +from . import markdown app.cli.add_command(refresh) + +__all__ = ['refresh', 'markdown'] diff --git a/funnel/cli/refresh/markdown.py b/funnel/cli/refresh/markdown.py index 544883b5e..d8cdb4c91 100644 --- a/funnel/cli/refresh/markdown.py +++ b/funnel/cli/refresh/markdown.py @@ -45,10 +45,7 @@ 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 - if config: - fields = self.config_fields[config] - else: - fields = self.fields + fields = self.config_fields[config] if config else self.fields iter_list: Iterable[_M] diff --git a/funnel/devtest.py b/funnel/devtest.py index f26344de8..a9576a2a0 100644 --- a/funnel/devtest.py +++ b/funnel/devtest.py @@ -13,16 +13,26 @@ import socket import time import weakref -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator +from contextlib import AbstractContextManager from secrets import token_urlsafe -from typing import Any, NamedTuple, Protocol, cast +from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, Self, cast from flask import Flask +from rich.console import Console +from werkzeug.debug import DebuggedApplication +from werkzeug.debug.tbtools import DebugTraceback +from werkzeug.wrappers import Response from . import all_apps, app as main_app, transports from .models import db from .typing import ReturnView +if TYPE_CHECKING: + from multiprocessing.process import BaseProcess + + from _typeshed.wsgi import StartResponse, WSGIEnvironment + __all__ = ['AppByHostWsgi', 'BackgroundWorker', 'devtest_app'] # Devtest requires `fork`. The default `spawn` method on macOS and Windows will @@ -34,7 +44,68 @@ # `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` mpcontext = multiprocessing.get_context('fork') -# --- Development and testing app multiplexer ------------------------------------------ +# MARK: Werkzeug debugger + + +class RichDebuggedApplication(DebuggedApplication): + """Werkzeug's DebuggedApplication augmented with rich.traceback in the console.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.error_console = Console(stderr=True) + + # This function replicates the code of the original, except the last line, and + # may need periodic sync with upstream + def debug_application( + self, environ: WSGIEnvironment, start_response: StartResponse + ) -> Iterator[bytes]: + """Run the application and conserve the traceback frames.""" + contexts: list[AbstractContextManager[Any]] = [] + + if self.evalex: + environ['werkzeug.debug.preserve_context'] = contexts.append + + app_iter = None + try: + app_iter = self.app(environ, start_response) + yield from app_iter + if hasattr(app_iter, 'close'): + app_iter.close() # pyright: ignore[reportAttributeAccessIssue] + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + if hasattr(app_iter, 'close'): + app_iter.close() # type: ignore[union-attr] + + tb = DebugTraceback(e, skip=1, hide=not self.show_hidden_frames) + + for frame in tb.all_frames: + self.frames[id(frame)] = frame + self.frame_contexts[id(frame)] = contexts # pyright: ignore[reportArgumentType] + + is_trusted = bool(self.check_pin_trust(environ)) + html = tb.render_debugger_html( + evalex=self.evalex, + secret=self.secret, + evalex_trusted=is_trusted, + ) + response = Response(html, status=500, mimetype='text/html') + + try: + yield from response(environ, start_response) + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught + # if we end up here there has been output but an error + # occurred. in that situation we can do nothing fancy any + # more, better log something into the error log and fall + # back gracefully. + self.error_console.print( + "Debugging middleware caught exception in streamed " + "response at a point where response headers were already " + "sent." + ) + + self.error_console.print_exception(show_locals=True, width=None) + + +# MARK: Development and testing app multiplexer ---------------------------------------- info_app = Flask(__name__) @@ -44,7 +115,7 @@ def info_index(_ignore_path: str = '') -> ReturnView: """Info app provides a guide to access the server.""" info = "Add the following entries to /etc/hosts to access:\n\n" - max_host_len = max(len(host) for host in devtest_app.apps_by_host.keys()) + max_host_len = max(len(host) for host in devtest_app.apps_by_host) for host, app in devtest_app.apps_by_host.items(): space_padding = ' ' * (max_host_len - len(host) + 2) info += ( @@ -103,7 +174,7 @@ def __call__(self, environ: Any, start_response: Any) -> Iterable[bytes]: devtest_app = AppByHostWsgi(*all_apps) -# --- Background worker ---------------------------------------------------------------- +# MARK: Background worker -------------------------------------------------------------- class HostPort(NamedTuple): @@ -116,7 +187,7 @@ class HostPort(NamedTuple): class CapturedSms(NamedTuple): phone: str message: str - vars: dict[str, str] # noqa: A003 + vars: dict[str, str] class CapturedEmail(NamedTuple): @@ -203,10 +274,10 @@ def mock_email( subject: str, to: list[Any], content: str, - attachments: Any = None, + attachments: Any = None, # noqa: ARG001 from_email: Any | None = None, - headers: dict | None = None, - base_url: str | None = None, + headers: dict | None = None, # noqa: ARG001 + base_url: str | None = None, # noqa: ARG001 ) -> str: capture = CapturedEmail( subject, @@ -221,7 +292,7 @@ def mock_email( def mock_sms( phone: Any, message: transports.sms.SmsTemplate, - callback: bool = True, + callback: bool = True, # noqa: ARG001 ) -> str: capture = CapturedSms(str(phone), str(message), message.vars()) calls.sms.append(capture) @@ -254,8 +325,8 @@ class BackgroundWorker: def __init__( self, worker: Callable, - args: Iterable | None = None, - kwargs: dict | None = None, + args: Iterable[Any] | None = None, + kwargs: dict[str, Any] | None = None, probe_at: tuple[str, int] | None = None, timeout: int = 10, clean_stop: bool = True, @@ -269,7 +340,7 @@ def __init__( self.timeout = timeout self.clean_stop = clean_stop self.daemon = daemon - self._process: multiprocessing.context.ForkProcess | None = None + self._process: BaseProcess | None = None self.mock_transports = mock_transports manager = mpcontext.Manager() @@ -372,11 +443,11 @@ def __repr__(self) -> str: ) return f"" - def __enter__(self) -> BackgroundWorker: + def __enter__(self) -> Self: """Start server in a context manager.""" self.start() return self - def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: """Finalise a context manager.""" self.stop() diff --git a/funnel/extapi/boxoffice.py b/funnel/extapi/boxoffice.py index af9d713d9..99a3fa28f 100644 --- a/funnel/extapi/boxoffice.py +++ b/funnel/extapi/boxoffice.py @@ -23,7 +23,7 @@ def __init__(self, access_token: str, base_url: str | None = None) -> None: else: self.base_url = base_url - def get_orders(self, ic: str): # TODO: Return type annotation + def get_orders(self, ic: str) -> list[dict]: # TODO: Return type annotation url = urljoin( self.base_url, f'ic/{ic}/orders?access_token={self.access_token}', @@ -33,25 +33,25 @@ def get_orders(self, ic: str): # TODO: Return type annotation def get_tickets(self, ic: str) -> list[ExtTicketsDict]: tickets: list[ExtTicketsDict] = [] for order in self.get_orders(ic): - for line_item in order.get('line_items'): - if line_item.get('assignee'): + for line_item in order.get('line_items', []): + if assignee := line_item.get('assignee', {}): status = line_item.get('line_item_status') tickets.append( { - 'fullname': line_item.get('assignee').get('fullname', ''), - 'email': line_item.get('assignee').get('email'), - 'phone': line_item.get('assignee').get('phone', ''), + 'fullname': assignee.get('fullname', ''), + 'email': assignee.get('email'), + 'phone': assignee.get('phone', ''), 'twitter': extract_twitter_handle( - line_item.get('assignee').get('twitter', '') + assignee.get('twitter', '') ), - 'company': line_item.get('assignee').get('company'), - 'city': line_item.get('assignee').get('city', ''), - 'job_title': line_item.get('assignee').get('jobtitle', ''), - 'ticket_no': str(line_item.get('line_item_seq')), - 'ticket_type': line_item.get('item', {}).get('title', '')[ + 'company': assignee.get('company'), + 'city': assignee.get('city', ''), + 'job_title': assignee.get('jobtitle', ''), + 'ticket_no': str(line_item.get('line_item_seq', '')), + 'ticket_type': line_item.get('ticket', {}).get('title', '')[ :80 ], - 'order_no': str(order.get('invoice_no')), + 'order_no': str(order.get('invoice_no', '')), 'status': status, } ) diff --git a/funnel/extapi/explara.py b/funnel/extapi/explara.py index 6f02c020d..dce1fcb46 100644 --- a/funnel/extapi/explara.py +++ b/funnel/extapi/explara.py @@ -20,10 +20,10 @@ class ExplaraAttendeeDict(TypedDict, total=False): name: str email: str Phone: str - phoneNo: str # noqa: N815 - ticketNo: str # noqa: N815 - ticketName: str # noqa: N815 - orderNo: str # noqa: N815 + phoneNo: str + ticketNo: str + ticketName: str + orderNo: str status: str diff --git a/funnel/forms/__init__.py b/funnel/forms/__init__.py index 8f1ec1237..b9117ff85 100644 --- a/funnel/forms/__init__.py +++ b/funnel/forms/__init__.py @@ -1,6 +1,6 @@ """Forms provide an interface between views and models, validating data before save.""" -# --- Everything below this line is auto-generated using `make initpy` ----------------- +# MARK: Everything below this line is auto-generated using `make initpy` --------------- from . import ( account, @@ -92,6 +92,7 @@ ) from .organization import OrganizationForm, TeamForm from .profile import ( + FollowForm, ProfileBannerForm, ProfileForm, ProfileLogoForm, @@ -99,6 +100,7 @@ ) from .project import ( CfpForm, + ProjectAssignParentForm, ProjectBannerForm, ProjectCfpTransitionForm, ProjectFeaturedForm, @@ -147,6 +149,7 @@ "EmailOtpForm", "EmailPrimaryForm", "FORM_SCHEMA_PLACEHOLDER", + "FollowForm", "LabelForm", "LabelOptionForm", "LoginForm", @@ -180,6 +183,7 @@ "ProfileForm", "ProfileLogoForm", "ProfileTransitionForm", + "ProjectAssignParentForm", "ProjectBannerForm", "ProjectBoxofficeForm", "ProjectCfpTransitionForm", diff --git a/funnel/forms/account.py b/funnel/forms/account.py index bff2d3839..2fd5da9c6 100644 --- a/funnel/forms/account.py +++ b/funnel/forms/account.py @@ -4,7 +4,8 @@ from collections.abc import Iterable, Sequence from hashlib import sha1 -from typing import Any +from http import HTTPStatus +from typing import Any, NoReturn import requests from flask import url_for @@ -19,6 +20,7 @@ PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, Account, + AccountNameProblem, Anchor, User, check_password_strength, @@ -75,10 +77,14 @@ def __init__( self.user_input_fields = user_input_fields self.message = message or self.default_message - def __call__(self, form: forms.Form, field: forms.PasswordField) -> None: - user_inputs = [] - for field_name in self.user_input_fields: - user_inputs.append(getattr(form, field_name).data) + def __call__( + self, + form: PasswordChangeForm | PasswordCreateForm | PasswordResetForm, + field: forms.PasswordField, + ) -> None: + user_inputs = [ + getattr(form, field_name).data for field_name in self.user_input_fields + ] if (edit_user := getattr(form, 'edit_user', None)) is not None: if edit_user.username: @@ -86,22 +92,16 @@ def __call__(self, form: forms.Form, field: forms.PasswordField) -> None: if edit_user.fullname: user_inputs.append(edit_user.fullname) - for accountemail in edit_user.emails: - user_inputs.append(str(accountemail)) - for emailclaim in edit_user.emailclaims: - user_inputs.append(str(emailclaim)) - - for accountphone in edit_user.phones: - user_inputs.append(str(accountphone)) + user_inputs.extend(str(i) for i in edit_user.emails) + user_inputs.extend(str(i) for i in edit_user.emailclaims) + user_inputs.extend(str(i) for i in edit_user.phones) tested_password = check_password_strength( field.data or '', user_inputs=user_inputs if user_inputs else None ) # Stick password strength into the form for logging in the view and possibly # rendering into UI - form.password_strength = ( # pyright: ignore[reportGeneralTypeIssues] - tested_password.score - ) + form.password_strength = tested_password.score # No test failures? All good then if not tested_password.is_weak: return @@ -126,7 +126,7 @@ def pwned_password_validator(_form: Any, field: forms.PasswordField) -> None: try: rv = requests.get(f'https://api.pwnedpasswords.com/range/{prefix}', timeout=10) - if rv.status_code != 200: + if rv.status_code != HTTPStatus.OK: # API call had an error and we can't proceed with validation. return # This API returns minimal plaintext containing ``suffix:count``, one per line. @@ -150,7 +150,7 @@ def pwned_password_validator(_form: Any, field: forms.PasswordField) -> None: return # If we have data, check for our hash suffix in the returned range of matches - count = matches.get(suffix, None) + count = matches.get(suffix) if count: # not 0 and not None raise forms.validators.StopValidation( ngettext( @@ -212,14 +212,9 @@ def validate_password(self, field: forms.Field) -> None: if self.edit_user: if self.edit_user.fullname: user_inputs.append(self.edit_user.fullname) - - 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 accountphone in self.edit_user.phones: - user_inputs.append(str(accountphone)) + user_inputs.extend(str(i) for i in self.edit_user.emails) + user_inputs.extend(str(i) for i in self.edit_user.emailclaims) + user_inputs.extend(str(i) for i in self.edit_user.phones) tested_password = check_password_strength( field.data, user_inputs=user_inputs if user_inputs else None @@ -384,21 +379,26 @@ def validate_old_password(self, field: forms.Field) -> None: raise forms.validators.ValidationError(_("Incorrect password")) -def raise_username_error(reason: str) -> str: +def raise_username_error(reason: AccountNameProblem) -> NoReturn: """Provide a user-friendly error message for a username field error.""" - if reason == 'blank': - raise forms.validators.ValidationError(_("This is required")) - if reason == 'long': - raise forms.validators.ValidationError(_("This is too long")) - if reason == 'invalid': - raise forms.validators.ValidationError( - _("Usernames can only have alphabets, numbers and underscores") - ) - if reason == 'reserved': - raise forms.validators.ValidationError(_("This username is reserved")) - if reason in ('user', 'org'): - raise forms.validators.ValidationError(_("This username has been taken")) - raise forms.validators.ValidationError(_("This username is not available")) + match reason: + case AccountNameProblem.BLANK: + raise forms.validators.ValidationError(_("This is required")) + case AccountNameProblem.LONG: + raise forms.validators.ValidationError(_("This is too long")) + case AccountNameProblem.INVALID: + raise forms.validators.ValidationError( + _("Usernames can only have alphabets, numbers and underscores") + ) + case AccountNameProblem.RESERVED: + raise forms.validators.ValidationError(_("This username is reserved")) + case ( + AccountNameProblem.ACCOUNT + | AccountNameProblem.USER + | AccountNameProblem.ORG + | AccountNameProblem.PLACEHOLDER + ): + raise forms.validators.ValidationError(_("This username is taken")) @Account.forms('main') @@ -433,6 +433,12 @@ class AccountForm(forms.Form): 'autocapitalize': 'off', }, ) + tagline = forms.StringField( + __("Bio"), + validators=[forms.validators.Optional(), forms.validators.Length(max=160)], + filters=nullable_strip_filters, + description=__("A brief statement about yourself"), + ) timezone = forms.SelectField( __("Timezone"), description=__( diff --git a/funnel/forms/auth_client.py b/funnel/forms/auth_client.py index 7a5597ae3..7b4dab649 100644 --- a/funnel/forms/auth_client.py +++ b/funnel/forms/auth_client.py @@ -2,8 +2,6 @@ from __future__ import annotations -from urllib.parse import urlparse - from baseframe import _, __, forms from coaster.utils import getbool @@ -35,7 +33,7 @@ class AuthClientForm(forms.Form): title = forms.StringField( __("Application title"), - validators=[forms.validators.DataRequired()], + validators=[forms.validators.DataRequired(), forms.validators.Length(max=250)], filters=[forms.filters.strip()], description=__("The name of your application"), ) @@ -73,12 +71,14 @@ class AuthClientForm(forms.Form): ), ], ) + # FIXME: Allow multiple website URLs and validate against redirect URLs website = forms.URLField( __("Application website"), validators=[forms.validators.DataRequired(), forms.validators.URL()], filters=strip_filters, description=__("Website where users may access this application"), ) + # FIXME: Change validator to URI instead of URL, for native app URIs redirect_uris = forms.TextListField( __("Redirect URLs"), validators=[ @@ -115,26 +115,6 @@ def validate_client_owner(self, field: forms.Field) -> None: raise forms.validators.ValidationError(_("Invalid owner")) self.account = orgs[0] - def _urls_match(self, url1: str, url2: str) -> bool: - """Validate two URLs have the same base component (minus path).""" - p1 = urlparse(url1) - p2 = urlparse(url2) - return ( - (p1.netloc == p2.netloc) - and (p1.scheme == p2.scheme) - and (p1.username == p2.username) - and (p1.password == p2.password) - ) - - def validate_redirect_uri(self, field: forms.Field) -> None: - """Validate redirect URI points to the website for confidential clients.""" - if self.confidential.data and not self._urls_match( - self.website.data or '', field.data - ): - raise forms.validators.ValidationError( - _("The scheme, domain and port must match that of the website URL") - ) - @AuthClientCredential.forms('main') class AuthClientCredentialForm(forms.Form): @@ -150,7 +130,7 @@ class AuthClientCredentialForm(forms.Form): ) -def permission_validator(form: forms.Form, field: forms.Field) -> None: +def permission_validator(_form: forms.Form, field: forms.Field) -> None: """Validate permission strings to be appropriately named.""" permlist = field.data.split() for perm in permlist: diff --git a/funnel/forms/helpers.py b/funnel/forms/helpers.py index d789aafb0..c8e1d20c1 100644 --- a/funnel/forms/helpers.py +++ b/funnel/forms/helpers.py @@ -23,7 +23,7 @@ parse_video_url, ) -# --- Error messages ------------------------------------------------------------------- +# MARK: Error messages ----------------------------------------------------------------- MSG_EMAIL_INVALID = _("This does not appear to be a valid email address") MSG_EMAIL_BLOCKED = __("This email address has been blocked from use") @@ -145,17 +145,20 @@ def __call__(self, form: forms.Form, field: forms.Field) -> None: if has_error is not None: app.logger.error("Unknown email address validation code: %r", has_error) - 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 AccountEmailClaim.all(email=field.data).notempty(): - raise forms.validators.StopValidation( - _( - "You or someone else has made an account with this email" - " address but has not confirmed it. Do you need to reset your" - " password?" - ) + # One last check: is there an existing claim? If so, stop the user from making a + # dupe account + if ( + has_error is None + and self.purpose == 'register' + and AccountEmailClaim.all(email=field.data).notempty() + ): + raise forms.validators.StopValidation( + _( + "You or someone else has made an account with this email" + " address but has not confirmed it. Do you need to reset your" + " password?" ) + ) class PhoneNumberAvailable: @@ -237,7 +240,7 @@ def image_url_validator() -> forms.validators.ValidUrl: ) -def video_url_list_validator(form: forms.Form, field: forms.Field) -> None: +def video_url_list_validator(_form: forms.Form, field: forms.Field) -> None: """Validate all video URLs to be acceptable.""" for url in field.data: try: @@ -248,7 +251,7 @@ def video_url_list_validator(form: forms.Form, field: forms.Field) -> None: ) from None -def video_url_validator(form: forms.Form, field: forms.Field) -> None: +def video_url_validator(_form: forms.Form, field: forms.Field) -> None: """Validate the video URL to be acceptable.""" try: parse_video_url(field.data) @@ -274,7 +277,7 @@ def format_json(data: dict | str | None) -> str: return '' -def validate_and_convert_json(form: forms.Form, field: forms.Field) -> None: +def validate_and_convert_json(_form: forms.Form, field: forms.Field) -> None: """Confirm form data is valid JSON, and store it back as a parsed dict.""" if field.data is None: return diff --git a/funnel/forms/login.py b/funnel/forms/login.py index 4d5aa2646..767528c18 100644 --- a/funnel/forms/login.py +++ b/funnel/forms/login.py @@ -44,7 +44,7 @@ ] -# --- Exceptions ----------------------------------------------------------------------- +# MARK: Exceptions --------------------------------------------------------------------- class LoginPasswordResetException(Exception): # noqa: N818 @@ -63,7 +63,7 @@ class RegisterWithOtp(Exception): # noqa: N818 """Exception to signal for new account registration after OTP validation.""" -# --- Validators ----------------------------------------------------------------------- +# MARK: Validators --------------------------------------------------------------------- # Validator specifically for LoginForm @@ -90,7 +90,7 @@ def __call__(self, form: LoginForm, field: forms.PasswordField) -> None: raise forms.validators.StopValidation(self.message) -# --- Forms ---------------------------------------------------------------------------- +# MARK: Forms -------------------------------------------------------------------------- @Account.forms('login') @@ -214,7 +214,7 @@ def validate_password(self, field: forms.Field) -> None: # From here on `self.user` is guaranteed to be a `User` instance, but mypy # can't infer and must be told if TYPE_CHECKING: - assert isinstance(self.user, User) # nosec + assert isinstance(self.user, User) # If user does not have a password, ask for a password reset. Since # :class:`PasswordlessLoginIntercept` already intercepts for a blank password, diff --git a/funnel/forms/organization.py b/funnel/forms/organization.py index ee6f712ae..daf3d8f7e 100644 --- a/funnel/forms/organization.py +++ b/funnel/forms/organization.py @@ -9,7 +9,7 @@ from baseframe import _, __, forms -from ..models import Account, Team, User +from ..models import Account, AccountNameProblem, Team, User __all__ = ['OrganizationForm', 'TeamForm'] @@ -51,46 +51,43 @@ class OrganizationForm(forms.Form): ) def validate_name(self, field: forms.Field) -> None: - """Validate name is valid and available for this organization.""" - reason = Account.validate_name_candidate(field.data) + """Validate name is valid and available for this account.""" + if self.edit_obj: + reason = self.edit_obj.validate_new_name(field.data) + else: + reason = Account.validate_name_candidate(field.data) if not reason: return # name is available - if reason == 'invalid': - raise forms.validators.ValidationError( - _("Names can only have alphabets, numbers and underscores") - ) - if reason == 'reserved': - raise forms.validators.ValidationError(_("This name is reserved")) - if ( - self.edit_obj - and self.edit_obj.name - and field.data.lower() == self.edit_obj.name.lower() - ): - # Name has only changed case from previous name. This is a validation pass - return - if reason == 'user': - if ( - self.edit_user.username - and field.data.lower() == self.edit_user.username.lower() - ): + match reason: + case AccountNameProblem.INVALID: raise forms.validators.ValidationError( - Markup( - _( - "This is your current username." - ' You must change it first from your' - " account before you can assign it to an organization" - ).format(account=url_for('account')) + _("Names can only have alphabets, numbers and underscores") + ) + case AccountNameProblem.RESERVED: + raise forms.validators.ValidationError(_("This name is reserved")) + case AccountNameProblem.USER: + if self.edit_user.name_is(field.data): + raise forms.validators.ValidationError( + Markup( + _( + 'This is your current username.' + ' You must change it first from your account before you can assign it' + ' to an organization' + ).format(account=url_for('account')) + ) ) + raise forms.validators.ValidationError( + _("This name has been taken by another user") + ) + case AccountNameProblem.ORG: + raise forms.validators.ValidationError( + _("This name has been taken by another organization") + ) + case _: + raise forms.validators.ValidationError( + _("This name has been taken by another account") ) - raise forms.validators.ValidationError( - _("This name has been taken by another user") - ) - if reason == 'org': - raise forms.validators.ValidationError( - _("This name has been taken by another organization") - ) - # We're not supposed to get an unknown reason. Flag error to developers. - raise ValueError(f"Unknown account name validation failure reason: {reason}") @Team.forms('main') diff --git a/funnel/forms/profile.py b/funnel/forms/profile.py index dc0815141..43f644529 100644 --- a/funnel/forms/profile.py +++ b/funnel/forms/profile.py @@ -13,6 +13,7 @@ 'ProfileLogoForm', 'ProfileBannerForm', 'ProfileTransitionForm', + 'FollowForm', ] @@ -61,6 +62,10 @@ class ProfileForm(OrganizationForm): def __post_init__(self) -> None: """Prepare form for use.""" self.logo_url.profile = self.account.name or self.account.buid + if self.account.is_user_profile: + self.make_for_user() + if not self.account.is_verified: + del self.description def make_for_user(self) -> None: """Customise form for a user account.""" @@ -148,3 +153,10 @@ def __post_init__(self) -> None: """Prepare form for use.""" self.banner_image_url.widget_type = 'modal' self.banner_image_url.profile = self.account.name or self.account.buid + + +@Account.forms('follow') +class FollowForm(forms.Form): + """Form for following or unfollowing an account.""" + + follow = forms.BooleanField(__("Follow?")) diff --git a/funnel/forms/project.py b/funnel/forms/project.py index 633808ce0..e2f812d1e 100644 --- a/funnel/forms/project.py +++ b/funnel/forms/project.py @@ -6,7 +6,7 @@ from typing import cast from baseframe import _, __, forms -from baseframe.forms.sqlalchemy import AvailableName +from baseframe.forms.sqlalchemy import AvailableName, QuerySelectField from coaster.utils import sorted_timezones, utcnow from ..models import Account, Project, Rsvp, SavedProject @@ -32,6 +32,7 @@ 'RsvpTransitionForm', 'SavedProjectForm', 'ProjectRegisterForm', + 'ProjectAssignParentForm', ] double_quote_re = re.compile(r'["“”]') @@ -51,7 +52,10 @@ class ProjectForm(forms.Form): title = forms.StringField( __("Title"), - validators=[forms.validators.DataRequired()], + validators=[ + forms.validators.DataRequired(), + forms.validators.Length(max=Project.__title_length__), + ], filters=[forms.filters.strip()], ) tagline = forms.StringField( @@ -291,7 +295,7 @@ def __post_init__(self) -> None: class ProjectCfpTransitionForm(forms.Form): """Form for transitioning a project's submission state.""" - open = forms.BooleanField( # noqa: A003 + open = forms.BooleanField( __("Open submissions"), validators=[forms.validators.InputRequired()] ) @@ -378,14 +382,14 @@ class ProjectRegisterForm(forms.Form): ) def validate_form(self, field: forms.Field) -> None: - if not self.form.data: + if not field.data: return - if self.form.data and not self.schema: + if field.data and not self.schema: raise forms.validators.StopValidation( _("This registration is not expecting any form fields") ) if self.schema: - form_keys = set(cast(dict, self.form.data).keys()) + form_keys = set(cast(dict, field.data).keys()) schema_keys = {i['name'] for i in self.schema['fields']} if not form_keys.issubset(schema_keys): invalid_keys = form_keys.difference(schema_keys) @@ -394,3 +398,27 @@ def validate_form(self, field: forms.Field) -> None: fields=', '.join(invalid_keys) ) ) + + +@Project.forms('assign_parent') +class ProjectAssignParentForm(forms.Form): + """Form to assign a parent project to the project.""" + + __expects__ = ('user',) + user: Account + + parent_project = QuerySelectField( + __("Assign a parent project"), + description=__( + "This is to group related projects. Parent and subprojects will" + " appear under related events" + ), + validators=[forms.validators.Optional()], + get_label=lambda s: f'{s.account.title}: {s.title}' if s else '', + allow_blank=True, + blank_text='None', + ) + + def __post_init__(self) -> None: + """Prepare form for use.""" + self.parent_project.query = self.user.projects_as_editor diff --git a/funnel/forms/proposal.py b/funnel/forms/proposal.py index deb243220..a0c46c07b 100644 --- a/funnel/forms/proposal.py +++ b/funnel/forms/proposal.py @@ -58,11 +58,9 @@ class ProposalLabelForm(forms.Form): ), ) - form = ProposalLabelForm( + return ProposalLabelForm( obj=proposal.formlabels if proposal is not None else None, meta={'csrf': False} ) - del form.form_nonce - return form def proposal_label_admin_form( @@ -102,11 +100,9 @@ class ProposalLabelAdminForm(forms.Form): ), ) - form = ProposalLabelAdminForm( + return ProposalLabelAdminForm( obj=proposal.formlabels if proposal is not None else None, meta={'csrf': False} ) - del form.form_nonce - return form @Proposal.forms('featured') diff --git a/funnel/forms/session.py b/funnel/forms/session.py index 2bec70d1e..f8796ce87 100644 --- a/funnel/forms/session.py +++ b/funnel/forms/session.py @@ -17,7 +17,10 @@ class SessionForm(forms.Form): title = forms.StringField( __("Title"), - validators=[forms.validators.DataRequired()], + validators=[ + forms.validators.DataRequired(), + forms.validators.Length(max=Session.__title_length__), + ], filters=[forms.filters.strip()], ) venue_room_id = forms.SelectField( diff --git a/funnel/forms/sync_ticket.py b/funnel/forms/sync_ticket.py index f4f5a03b4..aacdc15d7 100644 --- a/funnel/forms/sync_ticket.py +++ b/funnel/forms/sync_ticket.py @@ -88,6 +88,11 @@ class ProjectBoxofficeForm(forms.Form): filters=[forms.filters.strip()], description=__("Optional – Use with care to replace the button text"), ) + buy_btn_eyebrow_txt = forms.StringField( + __("Buy button eyebrow text"), + filters=[forms.filters.strip()], + description=__("Optional – This text appears above the buy button"), + ) register_form_schema = forms.StylesheetField( __("Registration form"), description=__("Optional – Specify fields as JSON (limited support)"), @@ -159,7 +164,7 @@ class TicketTypeForm(forms.Form): option_widget=forms.CheckboxInput(), allow_blank=True, get_label='title', - query_factory=lambda: [], + query_factory=list, ) diff --git a/funnel/forms/update.py b/funnel/forms/update.py index 9fcb4b2cd..53b11613b 100644 --- a/funnel/forms/update.py +++ b/funnel/forms/update.py @@ -4,7 +4,7 @@ from baseframe import __, forms -from ..models import Update +from ..models import VISIBILITY_STATE, Update __all__ = ['UpdateForm', 'UpdatePinForm'] @@ -15,7 +15,10 @@ class UpdateForm(forms.Form): title = forms.StringField( __("Title"), - validators=[forms.validators.DataRequired()], + validators=[ + forms.validators.DataRequired(), + forms.validators.Length(max=Update.__title_length__), + ], filters=[forms.filters.strip()], ) body = forms.MarkdownField( @@ -23,11 +26,24 @@ class UpdateForm(forms.Form): validators=[forms.validators.DataRequired()], description=__("Markdown formatting is supported"), ) - is_pinned = forms.BooleanField( - __("Pin this update above other updates"), default=False - ) - is_restricted = forms.BooleanField( - __("Limit access to current participants only"), default=False + visibility = forms.RadioField( + __("Who gets this update?"), + description=__("This can’t be changed after publishing the update"), + default=VISIBILITY_STATE[VISIBILITY_STATE.PUBLIC].name, + choices=[ + ( + VISIBILITY_STATE[VISIBILITY_STATE.PUBLIC].name, + __("Public; account followers will be notified"), + ), + ( + VISIBILITY_STATE[VISIBILITY_STATE.MEMBERS].name, + __("Only account members"), + ), + ( + VISIBILITY_STATE[VISIBILITY_STATE.PARTICIPANTS].name, + __("Only project participants"), + ), + ], ) diff --git a/funnel/forms/venue.py b/funnel/forms/venue.py index 8df27ce76..59efc719a 100644 --- a/funnel/forms/venue.py +++ b/funnel/forms/venue.py @@ -25,7 +25,10 @@ class VenueForm(forms.Form): title = forms.StringField( __("Name"), description=__("Name of the venue"), - validators=[forms.validators.DataRequired(), forms.validators.Length(max=250)], + validators=[ + forms.validators.DataRequired(), + forms.validators.Length(max=Venue.__title_length__), + ], filters=[forms.filters.strip()], ) description = forms.MarkdownField( @@ -88,7 +91,10 @@ class VenueRoomForm(forms.Form): title = forms.StringField( __("Name"), description=__("Name of the room"), - validators=[forms.validators.DataRequired(), forms.validators.Length(max=250)], + validators=[ + forms.validators.DataRequired(), + forms.validators.Length(max=VenueRoom.__title_length__), + ], filters=[forms.filters.strip()], ) description = forms.MarkdownField( diff --git a/funnel/loginproviders/__init__.py b/funnel/loginproviders/__init__.py index 2f8c5277d..b06dfac0a 100644 --- a/funnel/loginproviders/__init__.py +++ b/funnel/loginproviders/__init__.py @@ -1,6 +1,6 @@ """Login provider implementations.""" -# --- Everything below this line is auto-generated using `make initpy` ----------------- +# MARK: Everything below this line is auto-generated using `make initpy` --------------- from . import github, google, helpers, linkedin, twitter, zoom from .github import GitHubProvider diff --git a/funnel/loginproviders/github.py b/funnel/loginproviders/github.py index b070909dc..fccb0e33a 100644 --- a/funnel/loginproviders/github.py +++ b/funnel/loginproviders/github.py @@ -18,7 +18,7 @@ class GitHubProvider(LoginProvider): at_username = True auth_url = 'https://github.com/login/oauth/authorize' - token_url = 'https://github.com/login/oauth/access_token' # nosec + token_url = 'https://github.com/login/oauth/access_token' # noqa: S105 user_info_url = 'https://api.github.com/user' user_emails_url = 'https://api.github.com/user/emails' @@ -45,7 +45,7 @@ def callback(self) -> LoginProviderData: _("This server’s callback URL is misconfigured") ) raise LoginCallbackError(_("Unknown failure")) - code = request.args.get('code', None) + code = request.args.get('code') try: response = requests.post( self.token_url, @@ -83,13 +83,15 @@ def callback(self) -> LoginProviderData: ) from exc email = None - emails = [] - if ghemails and isinstance(ghemails, (list, tuple)): - for result in ghemails: - if result.get('verified') and not result['email'].endswith( - '@users.noreply.github.com' - ): - emails.append(result['email']) + if ghemails and isinstance(ghemails, list | tuple): + emails = [ + item['email'] + for item in ghemails + if item.get('verified') + and not item['email'].endswith('@users.noreply.github.com') + ] + else: + emails = [] if emails: email = emails[0] return LoginProviderData( diff --git a/funnel/loginproviders/google.py b/funnel/loginproviders/google.py index 5de7eb1d9..163aed074 100644 --- a/funnel/loginproviders/google.py +++ b/funnel/loginproviders/google.py @@ -40,7 +40,7 @@ def callback(self) -> LoginProviderData: if request.args['error'] == 'access_denied': raise LoginCallbackError(_("You denied the Google login request")) raise LoginCallbackError(_("Unknown failure")) - code = request.args.get('code', None) + code = request.args.get('code') try: credentials = self.flow(callback_url).step2_exchange(code) response = requests.get( @@ -55,6 +55,7 @@ def callback(self) -> LoginProviderData: }, ).json() except ( + TimeoutError, client.FlowExchangeError, requests.exceptions.RequestException, requests.exceptions.JSONDecodeError, diff --git a/funnel/loginproviders/linkedin.py b/funnel/loginproviders/linkedin.py index 48bd4e1bd..2014071e8 100644 --- a/funnel/loginproviders/linkedin.py +++ b/funnel/loginproviders/linkedin.py @@ -19,7 +19,7 @@ class LinkedInProvider(LoginProvider): auth_url = 'https://www.linkedin.com/oauth/v2/authorization?response_type=code' - token_url = 'https://www.linkedin.com/oauth/v2/accessToken' # nosec + token_url = 'https://www.linkedin.com/oauth/v2/accessToken' # noqa: S105 user_info = ( 'https://api.linkedin.com/v2/me?' 'projection=(id,localizedFirstName,localizedLastName)' @@ -61,7 +61,7 @@ def callback(self) -> LoginProviderData: _("This server’s callback URL is misconfigured") ) raise LoginCallbackError(_("Unknown failure")) - code = request.args.get('code', None) + code = request.args.get('code') try: response = requests.post( self.token_url, @@ -126,7 +126,7 @@ def callback(self) -> LoginProviderData: ) from exc email_address = '' - if 'elements' in email_info and email_info['elements']: + if email_info.get('elements'): email_address = email_info['elements'][0]['handle~']['emailAddress'] return LoginProviderData( diff --git a/funnel/loginproviders/zoom.py b/funnel/loginproviders/zoom.py index 8be72b3e6..3f8857f28 100644 --- a/funnel/loginproviders/zoom.py +++ b/funnel/loginproviders/zoom.py @@ -19,9 +19,9 @@ class ZoomProvider(LoginProvider): at_username = False - auth_url = 'https://zoom.us/oauth/authorize?response_type=code' # nosec - token_url = 'https://zoom.us/oauth/token?grant_type=authorization_code' # nosec - user_info_url = 'https://api.zoom.us/v2/users/me' # nosec + auth_url = 'https://zoom.us/oauth/authorize?response_type=code' + token_url = 'https://zoom.us/oauth/token?grant_type=authorization_code' # noqa: S105 + user_info_url = 'https://api.zoom.us/v2/users/me' def do(self, callback_url: str) -> ReturnView: session['oauth_callback'] = callback_url @@ -49,7 +49,7 @@ def callback(self) -> LoginProviderData: _("This server’s callback URL is misconfigured") ) raise LoginCallbackError(_("Unknown failure")) - code = request.args.get('code', None) + code = request.args.get('code') try: response = requests.post( self.token_url, diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index cd27c13a9..fad49d181 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -9,7 +9,7 @@ __protected__ = ['types'] -# --- Everything below this line is auto-generated using `make initpy` ----------------- +# MARK: Everything below this line is auto-generated using `make initpy` --------------- import lazy_loader @@ -18,15 +18,19 @@ __all__ = [ "ACCOUNT_STATE", "Account", + "AccountAdminNotification", + "AccountAdminRevokedNotification", "AccountAndAnchor", "AccountEmail", "AccountEmailClaim", "AccountExternalId", "AccountMembership", + "AccountNameProblem", "AccountOldId", "AccountPasswordNotification", "AccountPhone", "Anchor", + "AppenderQuery", "AuthClient", "AuthClientCredential", "AuthClientPermissions", @@ -39,12 +43,14 @@ "BaseScopedIdMixin", "BaseScopedIdNameMixin", "BaseScopedNameMixin", + "CheckinParticipantProtocol", "Comment", "CommentModeratorReport", "CommentReplyNotification", "CommentReportReceivedNotification", "Commentset", "CommentsetMembership", + "Community", "ContactExchange", "CoordinatesMixin", "Draft", @@ -98,7 +104,6 @@ "ModelUrlProtocol", "ModelUuidProtocol", "NewCommentNotification", - "NewUpdateNotification", "NoIdMixin", "Notification", "NotificationFor", @@ -108,8 +113,6 @@ "OptionalEmailAddressMixin", "OptionalPhoneNumberMixin", "Organization", - "OrganizationAdminMembershipNotification", - "OrganizationAdminMembershipRevokedNotification", "PASSWORD_MAX_LENGTH", "PASSWORD_MIN_LENGTH", "PROPOSAL_STATE", @@ -122,14 +125,16 @@ "Placeholder", "PreviewNotification", "Project", - "ProjectCrewMembershipNotification", - "ProjectCrewMembershipRevokedNotification", + "ProjectCrewNotification", + "ProjectCrewRevokedNotification", "ProjectLocation", "ProjectMembership", "ProjectRedirect", "ProjectRsvpStateEnum", "ProjectSponsorMembership", "ProjectStartingNotification", + "ProjectTomorrowNotification", + "ProjectUpdateNotification", "Proposal", "ProposalLabelProxy", "ProposalLabelProxyWrapper", @@ -170,6 +175,7 @@ "UrlType", "User", "UuidMixin", + "VISIBILITY_STATE", "Venue", "VenueRoom", "VideoError", @@ -197,6 +203,7 @@ "getextid", "getuser", "helpers", + "hybrid_method", "hybrid_property", "label", "login_session", @@ -219,6 +226,7 @@ "project_child_role_map", "project_child_role_set", "project_membership", + "project_venue_primary_table", "proposal", "proposal_membership", "quote_autocomplete_like", diff --git a/funnel/models/__init__.pyi b/funnel/models/__init__.pyi index ea0833f14..f9bd501d5 100644 --- a/funnel/models/__init__.pyi +++ b/funnel/models/__init__.pyi @@ -44,9 +44,11 @@ from .account import ( AccountEmail, AccountEmailClaim, AccountExternalId, + AccountNameProblem, AccountOldId, AccountPhone, Anchor, + Community, DuckTypeAccount, Organization, Placeholder, @@ -66,6 +68,7 @@ from .auth_client import ( AuthToken, ) from .base import ( + AppenderQuery, BaseIdNameMixin, BaseMixin, BaseNameMixin, @@ -93,6 +96,7 @@ from .base import ( db, declarative_mixin, declared_attr, + hybrid_method, hybrid_property, postgresql, relationship, @@ -168,16 +172,17 @@ from .notification import ( notification_web_types, ) from .notification_types import ( + AccountAdminNotification, + AccountAdminRevokedNotification, AccountPasswordNotification, CommentReplyNotification, CommentReportReceivedNotification, NewCommentNotification, - NewUpdateNotification, - OrganizationAdminMembershipNotification, - OrganizationAdminMembershipRevokedNotification, - ProjectCrewMembershipNotification, - ProjectCrewMembershipRevokedNotification, + ProjectCrewNotification, + ProjectCrewRevokedNotification, ProjectStartingNotification, + ProjectTomorrowNotification, + ProjectUpdateNotification, ProposalReceivedNotification, ProposalSubmittedNotification, RegistrationCancellationNotification, @@ -212,6 +217,7 @@ from .shortlink import Shortlink from .site_membership import SiteMembership from .sponsor_membership import ProjectSponsorMembership, ProposalSponsorMembership from .sync_ticket import ( + CheckinParticipantProtocol, SyncTicket, TicketClient, TicketEvent, @@ -228,7 +234,7 @@ from .typing import ( ModelUrlProtocol, ModelUuidProtocol, ) -from .update import Update +from .update import VISIBILITY_STATE, Update from .utils import ( AccountAndAnchor, IncompleteUserMigrationError, @@ -236,21 +242,25 @@ from .utils import ( getuser, merge_accounts, ) -from .venue import Venue, VenueRoom +from .venue import Venue, VenueRoom, project_venue_primary_table from .video_mixin import VideoError, VideoMixin, parse_video_url __all__ = [ "ACCOUNT_STATE", "Account", + "AccountAdminNotification", + "AccountAdminRevokedNotification", "AccountAndAnchor", "AccountEmail", "AccountEmailClaim", "AccountExternalId", "AccountMembership", + "AccountNameProblem", "AccountOldId", "AccountPasswordNotification", "AccountPhone", "Anchor", + "AppenderQuery", "AuthClient", "AuthClientCredential", "AuthClientPermissions", @@ -263,12 +273,14 @@ __all__ = [ "BaseScopedIdMixin", "BaseScopedIdNameMixin", "BaseScopedNameMixin", + "CheckinParticipantProtocol", "Comment", "CommentModeratorReport", "CommentReplyNotification", "CommentReportReceivedNotification", "Commentset", "CommentsetMembership", + "Community", "ContactExchange", "CoordinatesMixin", "Draft", @@ -322,7 +334,6 @@ __all__ = [ "ModelUrlProtocol", "ModelUuidProtocol", "NewCommentNotification", - "NewUpdateNotification", "NoIdMixin", "Notification", "NotificationFor", @@ -332,8 +343,6 @@ __all__ = [ "OptionalEmailAddressMixin", "OptionalPhoneNumberMixin", "Organization", - "OrganizationAdminMembershipNotification", - "OrganizationAdminMembershipRevokedNotification", "PASSWORD_MAX_LENGTH", "PASSWORD_MIN_LENGTH", "PROPOSAL_STATE", @@ -346,14 +355,16 @@ __all__ = [ "Placeholder", "PreviewNotification", "Project", - "ProjectCrewMembershipNotification", - "ProjectCrewMembershipRevokedNotification", + "ProjectCrewNotification", + "ProjectCrewRevokedNotification", "ProjectLocation", "ProjectMembership", "ProjectRedirect", "ProjectRsvpStateEnum", "ProjectSponsorMembership", "ProjectStartingNotification", + "ProjectTomorrowNotification", + "ProjectUpdateNotification", "Proposal", "ProposalLabelProxy", "ProposalLabelProxyWrapper", @@ -394,6 +405,7 @@ __all__ = [ "UrlType", "User", "UuidMixin", + "VISIBILITY_STATE", "Venue", "VenueRoom", "VideoError", @@ -421,6 +433,7 @@ __all__ = [ "getextid", "getuser", "helpers", + "hybrid_method", "hybrid_property", "label", "login_session", @@ -443,6 +456,7 @@ __all__ = [ "project_child_role_map", "project_child_role_set", "project_membership", + "project_venue_primary_table", "proposal", "proposal_membership", "quote_autocomplete_like", diff --git a/funnel/models/account.py b/funnel/models/account.py index feb50c8c7..63cbaf5f6 100644 --- a/funnel/models/account.py +++ b/funnel/models/account.py @@ -1,7 +1,7 @@ """Account model with subtypes, and account-linked personal data models.""" # pylint: disable=unnecessary-lambda,invalid-unary-operand-type -# pyright: reportGeneralTypeIssues=false +# pyright: reportGeneralTypeIssues=false,reportAttributeAccessIssue=false from __future__ import annotations @@ -9,6 +9,7 @@ import itertools from collections.abc import Iterable, Iterator, Sequence from datetime import datetime +from enum import Enum from typing import ( TYPE_CHECKING, Any, @@ -35,19 +36,20 @@ from baseframe import __ from coaster.sqlalchemy import ( DynamicAssociationProxy, - LazyRoleSet, RoleMixin, StateManager, add_primary_relationship, auto_init_default, failsafe_add, immutable, + role_check, with_roles, ) -from coaster.utils import LabeledEnum, newsecret, require_one_of, utcnow +from coaster.utils import LabeledEnum, NameTitle, newsecret, require_one_of, utcnow from ..typing import OptionalMigratedTables from .base import ( + AppenderQuery, BaseMixin, DynamicMapped, LocaleType, @@ -59,6 +61,7 @@ UrlType, UuidMixin, db, + hybrid_method, hybrid_property, relationship, sa, @@ -80,24 +83,37 @@ __all__ = [ 'ACCOUNT_STATE', + 'AccountNameProblem', 'Account', - 'deleted_account', - 'removed_account', - 'unknown_account', - 'User', - 'DuckTypeAccount', - 'AccountOldId', - 'Organization', - 'Team', - 'Placeholder', 'AccountEmail', 'AccountEmailClaim', - 'AccountPhone', 'AccountExternalId', + 'AccountOldId', + 'AccountPhone', 'Anchor', + 'Community', + 'deleted_account', + 'DuckTypeAccount', + 'Organization', + 'Placeholder', + 'removed_account', + 'Team', + 'unknown_account', + 'User', ] +class AccountNameProblem(Enum): + BLANK = 'blank' # Name is blank + RESERVED = 'reserved' # Name is a reserved keyword + INVALID = 'invalid' # Name has invalid syntax + LONG = 'long' # Name is too long + USER = 'user' # Name is taken by a user account + ORG = 'org' # Name is taken by an organization account + PLACEHOLDER = 'placeholder' # Name is taken by a placeholder account + ACCOUNT = 'account' # Name is taken by an account of unknown type + + class ACCOUNT_STATE(LabeledEnum): # noqa: N801 """State codes for accounts.""" @@ -117,12 +133,12 @@ class ACCOUNT_STATE(LabeledEnum): # noqa: N801 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")) + AUTO = (1, NameTitle('auto', __("Autogenerated"))) + PUBLIC = (2, NameTitle('public', __("Public"))) + PRIVATE = (3, NameTitle('private', __("Private"))) NOT_PUBLIC = {AUTO, PRIVATE} - NOT_PRIVATE = {AUTO, PUBLIC} + NOT_PRIVATE = {PUBLIC} class ZBase32Comparator(Comparator[str]): # pylint: disable=abstract-method @@ -138,7 +154,7 @@ def __eq__(self, other: object) -> sa.ColumnElement[bool]: # type: ignore[overr return sa.false() -# --- Tables --------------------------------------------------------------------------- +# MARK: Tables ------------------------------------------------------------------------- team_membership = sa.Table( 'team_membership', @@ -166,7 +182,7 @@ def __eq__(self, other: object) -> sa.ColumnElement[bool]: # type: ignore[overr ) -# --- Models --------------------------------------------------------------------------- +# MARK: Models ------------------------------------------------------------------------- class Account(UuidMixin, BaseMixin[int, 'Account'], Model): @@ -184,6 +200,7 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): # Helper flags (see subclasses) is_user_profile: ClassVar[bool] = False is_organization_profile: ClassVar[bool] = False + is_community_profile: ClassVar[bool] = False is_placeholder_profile: ClassVar[bool] = False reserved_names: ClassVar[set[str]] = RESERVED_NAMES @@ -205,6 +222,9 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): ), read={'all'}, ) + # TODO: Use a non-deterministic collation instead of a lowercase index + # https://www.postgresql.org/docs/current/collation.html#COLLATION-NONDETERMINISTIC + # SQLAlchemy data type parameter `collation='name'` #: The account's title (user's fullname) title: Mapped[str] = with_roles( @@ -243,7 +263,9 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.SmallInteger, - StateManager.check_constraint('state', ACCOUNT_STATE, sa.SmallInteger), + StateManager.check_constraint( + 'state', ACCOUNT_STATE, sa.SmallInteger, name='account_state_check' + ), nullable=False, default=ACCOUNT_STATE.ACTIVE, ) @@ -257,7 +279,12 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): _profile_state: Mapped[int] = sa_orm.mapped_column( 'profile_state', sa.SmallInteger, - StateManager.check_constraint('profile_state', PROFILE_STATE, sa.SmallInteger), + StateManager.check_constraint( + 'profile_state', + PROFILE_STATE, + sa.SmallInteger, + name='account_profile_state_check', + ), nullable=False, default=PROFILE_STATE.AUTO, ) @@ -281,9 +308,6 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): 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(default=False)), @@ -335,7 +359,7 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): deferred=True, ) - # --- Backrefs + # MARK: Backrefs # account.py: oldid: Mapped[AccountOldId] = relationship( @@ -370,20 +394,52 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): passive_deletes=True, back_populates='account', ) - active_admin_memberships: DynamicMapped[AccountMembership] = with_roles( + follower_memberships: DynamicMapped[AccountMembership] = with_roles( relationship( lazy='dynamic', primaryjoin=lambda: sa.and_( sa_orm.remote(AccountMembership.account_id) == Account.id, AccountMembership.is_active, ), - order_by=lambda: AccountMembership.granted_at.asc(), + order_by=lambda: AccountMembership.granted_at.desc(), viewonly=True, ), - grants_via={'member': {'admin', 'owner'}}, + read={'reader'}, + # Use offered_roles to determine which roles the user gets + grants_via={ + 'member': { + 'follower': 'follower', + 'member': 'member', + 'admin': 'admin', + 'owner': 'owner', + } + }, + ) + + @cached_property + def active_follower_memberships(self) -> AppenderQuery[AccountMembership]: + return self.follower_memberships.join(Account, AccountMembership.member).filter( + Account.state.ACTIVE + ) + + admin_memberships: DynamicMapped[AccountMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + sa_orm.remote(AccountMembership.account_id) == Account.id, + AccountMembership.is_active, + AccountMembership.is_admin.is_(True), + ), + order_by=lambda: AccountMembership.granted_at.asc(), + viewonly=True, ) - active_owner_memberships: DynamicMapped[AccountMembership] = relationship( + @cached_property + def active_admin_memberships(self) -> AppenderQuery[AccountMembership]: + return self.admin_memberships.join(Account, AccountMembership.member).filter( + Account.state.ACTIVE + ) + + owner_memberships: DynamicMapped[AccountMembership] = relationship( lazy='dynamic', primaryjoin=lambda: sa.and_( sa_orm.remote(AccountMembership.account_id) == Account.id, @@ -393,6 +449,12 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): viewonly=True, ) + @cached_property + def active_owner_memberships(self) -> AppenderQuery[AccountMembership]: + return self.owner_memberships.join(Account, AccountMembership.member).filter( + Account.state.ACTIVE + ) + active_invitations: DynamicMapped[AccountMembership] = relationship( lazy='dynamic', primaryjoin=lambda: sa.and_( @@ -403,14 +465,24 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): viewonly=True, ) - owner_users = with_roles( - DynamicAssociationProxy['Account']('active_owner_memberships', 'member'), + owner_users: DynamicAssociationProxy[Account, AccountMembership] = with_roles( + DynamicAssociationProxy( + 'active_owner_memberships', 'member', lambda: AccountMembership.member + ), read={'all'}, ) - admin_users = with_roles( - DynamicAssociationProxy['Account']('active_admin_memberships', 'member'), + admin_users: DynamicAssociationProxy[Account, AccountMembership] = with_roles( + DynamicAssociationProxy( + 'active_admin_memberships', 'member', lambda: AccountMembership.member + ), read={'all'}, ) + followers: DynamicAssociationProxy[Account, AccountMembership] = with_roles( + DynamicAssociationProxy( + 'active_follower_memberships', 'member', lambda: AccountMembership.member + ), + read={'reader'}, + ) organization_admin_memberships: DynamicMapped[AccountMembership] = relationship( lazy='dynamic', @@ -429,7 +501,6 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): viewonly=True, ) ) - active_organization_admin_memberships: DynamicMapped[AccountMembership] = ( relationship( lazy='dynamic', @@ -441,7 +512,6 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): viewonly=True, ) ) - active_organization_owner_memberships: DynamicMapped[AccountMembership] = ( relationship( lazy='dynamic', @@ -454,7 +524,6 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): viewonly=True, ) ) - active_organization_invitations: DynamicMapped[AccountMembership] = relationship( lazy='dynamic', foreign_keys=lambda: AccountMembership.member_id, @@ -465,13 +534,29 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): ), viewonly=True, ) - - organizations_as_owner = DynamicAssociationProxy['Account']( - 'active_organization_owner_memberships', 'account' + active_following_memberships: DynamicMapped[AccountMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + sa_orm.remote(AccountMembership.member_id) == Account.id, + AccountMembership.is_active, + # No filter on AccountMembership.is_follower flag because the `follower` + # role is implicit, regardless of what `is_follower` is set to + ), + order_by=lambda: AccountMembership.granted_at.asc(), + viewonly=True, ) - organizations_as_admin = DynamicAssociationProxy['Account']( - 'active_organization_admin_memberships', 'account' + organizations_as_owner: DynamicAssociationProxy[Account, AccountMembership] = ( + DynamicAssociationProxy('active_organization_owner_memberships', 'account') + ) + organizations_as_admin: DynamicAssociationProxy[Account, AccountMembership] = ( + DynamicAssociationProxy('active_organization_admin_memberships', 'account') + ) + accounts_following: DynamicAssociationProxy[Account, AccountMembership] = ( + with_roles( + DynamicAssociationProxy('active_following_memberships', 'account'), + read={'all'}, + ) ) # auth_client.py @@ -496,9 +581,9 @@ class Account(UuidMixin, BaseMixin[int, 'Account'], Model): )''', viewonly=True, ) - subscribed_commentsets = DynamicAssociationProxy['Commentset']( - 'active_commentset_memberships', 'commentset' - ) + subscribed_commentsets: DynamicAssociationProxy[ + Commentset, CommentsetMembership + ] = DynamicAssociationProxy('active_commentset_memberships', 'commentset') # contact_exchange.py scanned_contacts: DynamicMapped[ContactExchange] = relationship( @@ -604,8 +689,8 @@ def main_notification_preferences(self) -> NotificationPreferences: ) ) - projects_as_crew = DynamicAssociationProxy['Project']( - 'projects_as_crew_active_memberships', 'project' + projects_as_crew: DynamicAssociationProxy[Project, ProjectMembership] = ( + DynamicAssociationProxy('projects_as_crew_active_memberships', 'project') ) projects_as_editor_active_memberships: DynamicMapped[ProjectMembership] = ( @@ -620,8 +705,8 @@ def main_notification_preferences(self) -> NotificationPreferences: ) ) - projects_as_editor = DynamicAssociationProxy['Project']( - 'projects_as_editor_active_memberships', 'project' + projects_as_editor: DynamicAssociationProxy[Project, ProjectMembership] = ( + DynamicAssociationProxy('projects_as_editor_active_memberships', 'project') ) # project.py @@ -685,22 +770,22 @@ def main_notification_preferences(self) -> NotificationPreferences: ) # This is a User property of the proposals the user account is a collaborator in - proposals = DynamicAssociationProxy['Proposal']('proposal_memberships', 'proposal') + proposals: DynamicAssociationProxy[Proposal, ProposalMembership] = ( + DynamicAssociationProxy('proposal_memberships', 'proposal') + ) @property def public_proposal_memberships(self) -> Query[ProposalMembership]: """Query for all proposal memberships to proposals that are public.""" + # TODO: Include proposal state filter (pending proposal workflow fix) return ( self.proposal_memberships.join(Proposal, ProposalMembership.proposal) .join(Project, Proposal.project) - .filter( - ProposalMembership.is_uncredited.is_(False), - # TODO: Include proposal state filter (pending proposal workflow fix) - ) + .filter(ProposalMembership.is_uncredited.is_(False)) ) - public_proposals = DynamicAssociationProxy['Proposal']( - 'public_proposal_memberships', 'proposal' + public_proposals: DynamicAssociationProxy[Proposal, ProposalMembership] = ( + DynamicAssociationProxy('public_proposal_memberships', 'proposal') ) # proposal.py @@ -713,18 +798,6 @@ def public_proposal_memberships(self) -> Query[ProposalMembership]: lazy='dynamic', back_populates='participant' ) - @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'}) - # saved.py saved_projects: DynamicMapped[SavedProject] = relationship( lazy='dynamic', passive_deletes=True, back_populates='account' @@ -864,31 +937,19 @@ def is_site_admin(self) -> bool: ) ) - sponsored_projects = DynamicAssociationProxy['Project']( - 'project_sponsor_memberships', 'project' + sponsored_projects: DynamicAssociationProxy[Project, ProjectSponsorMembership] = ( + DynamicAssociationProxy('project_sponsor_memberships', 'project') ) - sponsored_proposals = DynamicAssociationProxy['Project']( - 'proposal_sponsor_memberships', 'proposal' - ) + sponsored_proposals: DynamicAssociationProxy[ + Proposal, ProposalSponsorMembership + ] = DynamicAssociationProxy('proposal_sponsor_memberships', 'proposal') # sync_ticket.py: ticket_participants: Mapped[list[TicketParticipant]] = relationship( back_populates='participant' ) - @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'}) - # update.py created_updates: DynamicMapped[Update] = relationship( lazy='dynamic', @@ -927,6 +988,7 @@ def ticket_followers(self) -> Query[Account]: 'polymorphic_on': type_, # When querying the Account model, cast automatically to all subclasses 'with_polymorphic': '*', + # Store a version id in this column to prevent edits to obsolete data 'version_id_col': revisionid, } @@ -943,6 +1005,7 @@ def ticket_followers(self) -> Query[Account]: 'timezone', 'description', 'website', + 'tagline', 'logo_url', 'banner_image_url', 'joined_at', @@ -997,6 +1060,7 @@ def ticket_followers(self) -> Query[Account]: 'ACTIVE_AND_PUBLIC', profile_state.PUBLIC, lambda account: bool(account.state.ACTIVE), + lambda account: account.state.ACTIVE.__clause_element__(), ) @classmethod @@ -1045,14 +1109,12 @@ def pickername(self) -> str: with_roles(pickername, read={'all'}) - def roles_for( - self, actor: Account | None = None, anchors: Sequence = () - ) -> 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 + @role_check('reader') + def has_reader_role( + self, _actor: Account | None, _anchors: Sequence[Any] = () + ) -> bool: + """Grant 'reader' role to all if the profile state is active and public.""" + return bool(self.profile_state.ACTIVE_AND_PUBLIC) @cached_property def verified_contact_count(self) -> int: @@ -1118,10 +1180,16 @@ def password_is(self, password: str, upgrade_hash: bool = False) -> bool: def add_email( self, email: str, - primary: bool = False, + primary: bool | None = None, private: bool = False, ) -> AccountEmail: - """Add an email address (assumed to be verified).""" + """ + Add an email address (assumed to be verified). + + :param email: Email address as a string + :param primary: Mark this email address as primary (default: auto-assign) + :param private: Mark as private (currently unused) + """ accountemail = AccountEmail(account=self, email=email, private=private) accountemail = failsafe_add( db.session, @@ -1129,7 +1197,7 @@ def add_email( account=self, email_address=accountemail.email_address, ) - if primary: + if (primary is None and self.primary_email is None) or primary is True: self.primary_email = accountemail return accountemail # FIXME: This should remove competing instances of AccountEmailClaim @@ -1172,10 +1240,16 @@ def email(self) -> Literal[''] | AccountEmail: def add_phone( self, phone: str, - primary: bool = False, + primary: bool | None = None, private: bool = False, ) -> AccountPhone: - """Add a phone number (assumed to be verified).""" + """ + Add a phone number (assumed to be verified). + + :param phone: Phone number as a string + :param primary: Mark this phone number as primary (default: auto-assign) + :param private: Mark as private (currently unused) + """ accountphone = AccountPhone(account=self, phone=phone, private=private) accountphone = failsafe_add( db.session, @@ -1183,7 +1257,7 @@ def add_phone( account=self, phone_number=accountphone.phone_number, ) - if primary: + if (primary is None and self.primary_phone is None) or primary is True: self.primary_phone = accountphone return accountphone @@ -1255,7 +1329,7 @@ def has_any_memberships(self) -> bool: for attr in self.__noninvite_membership_attrs__ ) - # --- Transport details + # MARK: Transport details @with_roles(call={'owner'}) def has_transport_email(self) -> bool: @@ -1291,7 +1365,10 @@ def has_transport_whatsapp(self) -> bool: ) @with_roles(call={'owner'}) - def transport_for_email(self, context: Model | None = None) -> AccountEmail | None: + def transport_for_email( + self, + context: Model | None = None, # noqa: ARG002 + ) -> AccountEmail | None: """Return user's preferred email address within a context.""" # TODO: Per-account/project customization is a future option if self.state.ACTIVE: @@ -1299,7 +1376,10 @@ def transport_for_email(self, context: Model | None = None) -> AccountEmail | No return None @with_roles(call={'owner'}) - def transport_for_sms(self, context: Model | None = None) -> AccountPhone | None: + def transport_for_sms( + self, + context: Model | None = None, # noqa: ARG002 + ) -> AccountPhone | None: """Return user's preferred phone number within a context.""" # TODO: Per-account/project customization is a future option if ( @@ -1312,21 +1392,24 @@ def transport_for_sms(self, context: Model | None = None) -> AccountPhone | None @with_roles(call={'owner'}) def transport_for_webpush( - self, context: Model | None = None + self, + context: Model | None = None, # noqa: ARG002 ) -> None: # TODO # pragma: no cover """Return user's preferred webpush transport address within a context.""" - return None + return @with_roles(call={'owner'}) def transport_for_telegram( - self, context: Model | None = None + self, + context: Model | None = None, # noqa: ARG002 ) -> None: # TODO # pragma: no cover """Return user's preferred Telegram transport address within a context.""" - return None + return @with_roles(call={'owner'}) def transport_for_whatsapp( - self, context: Model | None = None + self, + context: Model | None = None, # noqa: ARG002 ) -> AccountPhone | None: """Return user's preferred WhatsApp transport address within a context.""" # TODO: Per-account/project customization is a future option @@ -1384,16 +1467,19 @@ def default_email( return None @property - def _self_is_owner_and_admin_of_self(self) -> Account: + def _self_is_owner_of_self(self) -> Account | None: """ - Return self. + Return self in a user account. 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 + return self if self.is_user_profile else None - with_roles(_self_is_owner_and_admin_of_self, grants={'owner', 'admin'}) + with_roles( + _self_is_owner_of_self, + grants={'follower', 'member', 'admin', 'owner'}, + ) def organizations_as_owner_ids(self) -> list[int]: """ @@ -1444,7 +1530,7 @@ def do_delete(self) -> None: if callable( freeze := getattr(membership, 'freeze_member_attribution', None) ): - membership = freeze(self) + membership = freeze(self) # noqa: PLW2901 if membership.revoke_on_member_delete: membership.revoke(actor=self) # TODO: freeze fullname in unrevoked memberships (pending title column there) @@ -1516,8 +1602,18 @@ def _uuid_zbase32_comparator(cls) -> ZBase32Comparator: """Return SQL comparator for :prop:`uuid_zbase32`.""" return ZBase32Comparator(cls.uuid) # type: ignore[arg-type] + @hybrid_method + def name_is(self, name: str) -> bool: + if name.startswith('~'): + return self.uuid_zbase32 == name[1:] + return ( + self.name is not None + and self.name.lower() == name.replace('-', '_').lower() + ) + + @name_is.expression @classmethod - def name_is(cls, name: str) -> ColumnElement: + def _name_is_expr(cls, name: str) -> ColumnElement: """Generate query filter to check if name is matching (case insensitive).""" if name.startswith('~'): return cls.uuid_zbase32 == name[1:] @@ -1602,7 +1698,7 @@ def get( return None @classmethod - def all( # noqa: A003 + def all( cls, buids: Iterable[str] | None = None, names: Iterable[str] | None = None, @@ -1630,7 +1726,7 @@ def all( # noqa: A003 if defercols: query = query.options(*cls._defercols()) for account in query.all(): - account = account.merged_account() + account = account.merged_account() # noqa: PLW2901 if account.state.ACTIVE: accounts.add(account) return list(accounts) @@ -1736,46 +1832,42 @@ def autocomplete(cls, prefix: str) -> list[Self]: return users @classmethod - def validate_name_candidate(cls, name: str) -> str | None: + def validate_name_candidate(cls, name: str) -> AccountNameProblem | 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 + Returns ``None`` if all is okay, or :enum:`AccountNameProblem`. """ - if not name: - return 'blank' + if not name or not name.strip(): + return AccountNameProblem.BLANK if name.lower() in cls.reserved_names: - return 'reserved' + return AccountNameProblem.RESERVED if not valid_account_name(name): - return 'invalid' + return AccountNameProblem.INVALID if len(name) > cls.__name_length__: - return 'long' + return AccountNameProblem.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)) + Account.query.filter(Account.name_is(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' + match existing: + case User(): + return AccountNameProblem.USER + case Organization(): + return AccountNameProblem.ORG + case Placeholder(): + return AccountNameProblem.PLACEHOLDER + case _: + return AccountNameProblem.ACCOUNT return None - def validate_new_name(self, name: str) -> str | None: + def validate_new_name(self, name: str) -> AccountNameProblem | None: """Validate a new name for this account, returning an error code or None.""" - if self.name and name.lower() == self.name.lower(): + if self.name_is(name): return None return self.validate_name_candidate(name) @@ -1785,7 +1877,7 @@ def is_available_name(cls, name: str) -> bool: return cls.validate_name_candidate(name) is None @sa_orm.validates('name') - def _validate_name(self, key: str, value: str | None) -> str | None: + def _validate_name(self, _key: str, value: str | None) -> str | None: """Validate the value of Account.name.""" if value is None: return value @@ -1806,7 +1898,7 @@ def _validate_name(self, key: str, value: str | None) -> str | None: return value @sa_orm.validates('logo_url', 'banner_image_url') - def _validate_nullable(self, key: str, value: str | None) -> str | None: + def _validate_nullable(self, _key: str, value: str | None) -> str | None: """Convert blank values into None.""" return value if value else None @@ -1870,7 +1962,7 @@ def membership_project(self) -> Project | None: # 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 + type: Mapped[str] = sa_orm.synonym('type_') Account.__active_membership_attrs__.add('active_organization_admin_memberships') @@ -1942,7 +2034,7 @@ def __init__(self, **kwargs: Any) -> None: class DuckTypeAccount(RoleMixin): """User singleton constructor. Duck types a regular user object.""" - id: None = None # noqa: A003 + id: None = None created_at: None = None updated_at: None = None uuid: None = None @@ -2003,7 +2095,7 @@ def __format__(self, format_spec: str) -> str: return self.pickername return format(self.pickername, format_spec) - def url_for(self, *args: Any, **kwargs: Any) -> Literal['']: + def url_for(self, *_args: Any, **_kwargs: Any) -> Literal['']: """Return blank URL for anything to do with this user.""" return '' @@ -2013,7 +2105,7 @@ def url_for(self, *args: Any, **kwargs: Any) -> Literal['']: unknown_account = DuckTypeAccount(__("[unknown]")) -# --- Organizations and teams ------------------------------------------------- +# MARK: Organizations and teams ----------------------------------------------- class Organization(Account): @@ -2043,6 +2135,27 @@ def people(self) -> Query[Account]: ) +class Community(Account): + """ + A community account. + + Communities differ from organizations in having open-ended membership. + """ + + __mapper_args__ = {'polymorphic_identity': 'C'} + is_community_profile = True + + def __init__(self, owner: User, **kwargs: Any) -> 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 + ) + ) + + class Placeholder(Account): """A placeholder account.""" @@ -2077,6 +2190,7 @@ class Team(UuidMixin, BaseMixin[int, Account], Model): is_public: Mapped[bool] = sa_orm.mapped_column(default=False) # --- Backrefs + client_permissions: Mapped[list[AuthClientTeamPermissions]] = relationship( back_populates='team' ) @@ -2120,7 +2234,7 @@ def get(cls, buid: str, with_parent: bool = False) -> Team | None: return query.filter_by(buid=buid).one_or_none() -# --- Account email/phone and misc +# MARK: Account email/phone and misc class AccountEmail(EmailAddressMixin, BaseMixin[int, Account], Model): @@ -2159,6 +2273,8 @@ def __str__(self) -> str: # pylint: disable=invalid-str-returned """Email address as a string.""" return self.email or '' + __json__ = __str__ + @property def primary(self) -> bool: """Check whether this email address is the user's primary.""" @@ -2169,9 +2285,8 @@ 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 + elif self.account.primary_email == self: + self.account.primary_email = None @overload @classmethod @@ -2287,7 +2402,7 @@ def migrate_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] + return [cls.__table__.name, account_email_primary_table.name] class AccountEmailClaim(EmailAddressMixin, BaseMixin[int, Account], Model): @@ -2453,7 +2568,7 @@ def get_by( ) @classmethod - def all(cls, email: str) -> Query[Self]: # noqa: A003 + def all(cls, email: str) -> Query[Self]: """ Return all instances with the matching email address. @@ -2504,6 +2619,8 @@ def __str__(self) -> str: """Return phone number as a string.""" return self.phone or '' + __json__ = __str__ + @cached_property def parsed(self) -> phonenumbers.PhoneNumber | None: """Return parsed phone number using libphonenumber.""" @@ -2527,9 +2644,8 @@ def primary(self) -> bool: 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 + elif self.account.primary_phone == self: + self.account.primary_phone = None @overload @classmethod @@ -2645,7 +2761,7 @@ def migrate_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] + return [cls.__table__.name, account_phone_primary_table.name] class AccountExternalId(BaseMixin[int, Account], Model): @@ -2661,35 +2777,24 @@ class AccountExternalId(BaseMixin[int, Account], Model): account: Mapped[Account] = relationship(back_populates='externalids') 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) + service: Mapped[str] = sa_orm.mapped_column(sa.Unicode, 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) + userid: Mapped[str] = sa_orm.mapped_column(sa.Unicode, nullable=False) #: Optional public-facing username on the external service - # FIXME: change to sa.Unicode. LinkedIn once used full URLs - username: Mapped[str | None] = sa_orm.mapped_column(sa.UnicodeText, nullable=True) + username: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True) #: OAuth or OAuth2 access token - # FIXME: change to sa.Unicode - oauth_token: Mapped[str | None] = sa_orm.mapped_column( - sa.UnicodeText, nullable=True - ) + oauth_token: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, 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 + sa.Unicode, 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 + sa.Unicode, nullable=True ) #: OAuth2 refresh token - # FIXME: change to sa.Unicode oauth_refresh_token: Mapped[str | None] = sa_orm.mapped_column( - sa.UnicodeText, nullable=True + sa.Unicode, nullable=True ) #: OAuth2 token expiry in seconds, as sent by service provider oauth_expires_in: Mapped[int | None] = sa_orm.mapped_column() @@ -2758,10 +2863,10 @@ def get( return cls.query.filter_by(**{param: value, 'service': service}).one_or_none() -user_email_primary_table = add_primary_relationship( +account_email_primary_table = add_primary_relationship( Account, 'primary_email', AccountEmail, 'account', 'account_id' ) -user_phone_primary_table = add_primary_relationship( +account_phone_primary_table = add_primary_relationship( Account, 'primary_phone', AccountPhone, 'account', 'account_id' ) @@ -2791,7 +2896,7 @@ def get( if TYPE_CHECKING: from .auth_client import AuthClientTeamPermissions - from .comment import Comment, Commentset # noqa: F401 + from .comment import Comment, Commentset from .commentset_membership import CommentsetMembership from .contact_exchange import ContactExchange from .moderation import CommentModeratorReport diff --git a/funnel/models/account_membership.py b/funnel/models/account_membership.py index f1aefb8b7..82a6a52e8 100644 --- a/funnel/models/account_membership.py +++ b/funnel/models/account_membership.py @@ -2,12 +2,15 @@ from __future__ import annotations +from typing import Any, Self + +from sqlalchemy import event from werkzeug.utils import cached_property -from coaster.sqlalchemy import immutable, with_roles +from coaster.sqlalchemy import ImmutableColumnError, immutable, with_roles from .account import Account -from .base import Mapped, Model, relationship, sa, sa_orm +from .base import Mapped, Model, declared_attr, relationship, sa, sa_orm from .membership_mixin import ImmutableMembershipMixin __all__ = ['AccountMembership'] @@ -15,13 +18,17 @@ class AccountMembership(ImmutableMembershipMixin, Model): """ - An account can be a member of another account as an owner, admin or follower. + An account can be an owner, admin or follower of another account. - Owners can manage other administrators. + Owners can manage other owners and admins (members), but not followers. The term + 'member' has two distinct meanings in this model: - 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`. + 1. The subject of an :class:`AccountMembership` record is referred to as a member, + even if the record is revoked and therefore has no bearing on the parent + :class:`Account`. + 2. The :class:`Account` model recognises a 'member' role that is granted via an + :class:`AccountMembership` record, but only if specific flags are set (currently + :attr:`is_admin`). """ __tablename__ = 'account_membership' @@ -30,14 +37,18 @@ class AccountMembership(ImmutableMembershipMixin, Model): __null_granted_by__ = True #: List of role columns in this model - __data_columns__ = ('is_owner',) + __data_columns__ = ('is_follower', 'is_admin', 'is_owner', 'label') __roles__ = { 'all': { 'read': { 'urls', + 'offered_roles', 'member', + 'is_admin', 'is_owner', + 'is_follower', + 'label', 'account', 'granted_by', 'revoked_by', @@ -45,6 +56,7 @@ class AccountMembership(ImmutableMembershipMixin, Model): 'revoked_at', 'is_self_granted', 'is_self_revoked', + 'is_migrated', } }, 'account_admin': { @@ -60,6 +72,7 @@ class AccountMembership(ImmutableMembershipMixin, Model): 'is_invite', 'is_self_granted', 'is_self_revoked', + 'is_migrated', } }, } @@ -68,15 +81,35 @@ class AccountMembership(ImmutableMembershipMixin, Model): 'urls', 'uuid_b58', 'offered_roles', + 'is_admin', 'is_owner', + 'is_follower', + 'label', 'member', 'account', }, - 'without_parent': {'urls', 'uuid_b58', 'offered_roles', 'is_owner', 'member'}, - 'related': {'urls', 'uuid_b58', 'offered_roles', 'is_owner'}, + 'without_parent': { + 'urls', + 'uuid_b58', + 'offered_roles', + 'is_admin', + 'is_owner', + 'is_follower', + 'label', + 'member', + }, + 'related': { + 'urls', + 'uuid_b58', + 'offered_roles', + 'is_admin', + 'is_owner', + 'is_follower', + 'label', + }, } - #: Organization that this membership is being granted on + #: Account that this membership is being granted on account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id', ondelete='CASCADE'), default=None, @@ -92,11 +125,91 @@ class AccountMembership(ImmutableMembershipMixin, Model): # Organization roles: is_owner: Mapped[bool] = immutable(sa_orm.mapped_column(default=False)) + is_admin: Mapped[bool] = immutable(sa_orm.mapped_column(default=False)) + # This column tracks whether the member is explicitly a follower, or implicitly + # via being an admin. If implicit, revoking admin status must also revoke follower + # status + is_follower: Mapped[bool] = immutable(sa_orm.mapped_column(default=False)) + + #: Optional label, indicating the member's role in the account + label: Mapped[str | None] = immutable( + sa_orm.mapped_column( + sa.CheckConstraint("label <> ''", name='account_membership_label_check') + ) + ) + + @declared_attr.directive + @classmethod + def __table_args__(cls) -> tuple: # type: ignore[override] + """Table arguments.""" + try: + args = list(super().__table_args__) + except AttributeError: + args = [] + kwargs = args.pop(-1) if args and isinstance(args[-1], dict) else None + args.extend( + [ + # If is_owner is True, is_admin must also be True + sa.CheckConstraint( + sa.or_( + sa.and_( + cls.is_owner.is_(True), + cls.is_admin.is_(True), + ), + cls.is_owner.is_(False), + ), + name='account_membership_owner_is_admin_check', + ), + # Either is_admin or is_follower must be True + sa.CheckConstraint( + sa.or_( + cls.is_admin.is_(True), + cls.is_follower.is_(True), + ), + name='account_membership_admin_or_follower_check', + ), + ] + ) + if kwargs: + args.append(kwargs) + return tuple(args) @cached_property def offered_roles(self) -> set[str]: - """Roles offered by this membership record.""" - roles = {'admin'} + """Roles offered to the member via this membership record (if active).""" + # Admins and owners are always followers, and a record must have is_follower + # or is_admin True, so this guarantees the `follower` role is always present + roles = {'follower'} + if self.is_admin: + roles |= {'admin', 'member'} if self.is_owner: - roles.add('owner') + roles |= {'owner', 'member'} return roles + + @with_roles(call={'member'}) + def revoke_follower(self, actor: Account) -> Self | None: + """Make the member unfollow (potentially revoking the membership).""" + if self.is_admin: + return self.replace(actor, is_follower=False) + self.revoke(actor) + return None + + @with_roles(call={'account_admin'}) + def revoke_member(self, actor: Account) -> Self | None: + """Remove the member as admin/owner (potentially revoking the membership).""" + if self.is_follower: + return self.replace(actor, is_admin=False, is_owner=False) + self.revoke(actor) + return None + + +@event.listens_for(AccountMembership.is_owner, 'set') +def _ensure_owner_is_admin_too( + target: AccountMembership, value: Any, _old_value: Any, _initiator: Any +) -> None: + if value: + try: + target.is_admin = True + except ImmutableColumnError: + # Bypass the protection of the immutable validator + target.__dict__['is_admin'] = True diff --git a/funnel/models/auth_client.py b/funnel/models/auth_client.py index 49c9e3306..144d61f2f 100644 --- a/funnel/models/auth_client.py +++ b/funnel/models/auth_client.py @@ -8,6 +8,7 @@ from hashlib import blake2b, sha256 from typing import Any, Self, cast, overload +from furl import furl from sqlalchemy.orm import attribute_keyed_dict, load_only from sqlalchemy.orm.query import Query as QueryBaseClass from werkzeug.utils import cached_property @@ -23,6 +24,7 @@ Mapped, Model, Query, + UrlType, UuidMixin, db, declarative_mixin, @@ -54,7 +56,7 @@ class ScopeMixin: def _scope(cls) -> Mapped[str]: """Database column for storing scopes as a space-separated string.""" return sa_orm.mapped_column( - 'scope', sa.UnicodeText, nullable=cls.__scope_null_allowed__ + 'scope', sa.Unicode, nullable=cls.__scope_null_allowed__ ) @property @@ -116,18 +118,16 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin[int, Account], Model): sa_orm.mapped_column(), read={'all'}, write={'owner'} ) #: Website - website: Mapped[str] = with_roles( - sa_orm.mapped_column(sa.UnicodeText, nullable=False), # FIXME: Use UrlType - read={'all'}, - write={'owner'}, + website: Mapped[furl] = with_roles( + sa_orm.mapped_column(UrlType, nullable=False), read={'all'}, write={'owner'} ) #: Redirect URIs (one or more) _redirect_uris: Mapped[str | None] = sa_orm.mapped_column( 'redirect_uri', sa.UnicodeText, nullable=True, default='' ) #: Back-end notification URI (TODO: deprecated, needs better architecture) - notification_uri: Mapped[str | None] = with_roles( # FIXME: Use UrlType - sa_orm.mapped_column(sa.UnicodeText, nullable=True, default=''), rw={'owner'} + notification_uri: Mapped[furl | None] = with_roles( + sa_orm.mapped_column(UrlType, nullable=True, default=''), rw={'owner'} ) #: Active flag active: Mapped[bool] = sa_orm.mapped_column(default=True) @@ -211,7 +211,7 @@ def host_matches(self, url: str) -> bool: if netloc: return netloc in ( urllib.parse.urlsplit(r).netloc - for r in (tuple(self.redirect_uris) + (self.website,)) + for r in (*tuple(self.redirect_uris), str(self.website)) ) return False @@ -243,9 +243,8 @@ def allow_access_for(self, actor: Account) -> bool: if self.account: if AuthClientPermissions.get(self, actor): return True - else: - if AuthClientTeamPermissions.all_for(self, actor).notempty(): - return True + elif AuthClientTeamPermissions.all_for(self, actor).notempty(): + return True return False @classmethod @@ -327,10 +326,8 @@ def secret_is(self, candidate: str | None, upgrade_hash: bool = False) -> bool: if not candidate: return False if self.secret_hash.startswith('blake2b$32$'): - return ( - self.secret_hash - == 'blake2b$32$' - + blake2b(candidate.encode(), digest_size=32).hexdigest() + return self.secret_hash == ( + 'blake2b$32$' + blake2b(candidate.encode(), digest_size=32).hexdigest() ) # Older credentials, before the switch to Blake2b: if self.secret_hash.startswith('sha256$'): @@ -391,7 +388,7 @@ class AuthCode(ScopeMixin, BaseMixin[int, Account], Model): code: Mapped[str] = sa_orm.mapped_column( sa.String(44), insert_default=newsecret, default=None, nullable=False ) - redirect_uri: Mapped[str] = sa_orm.mapped_column(sa.UnicodeText, nullable=False) + redirect_uri: Mapped[str] = sa_orm.mapped_column(sa.Unicode, nullable=False) used: Mapped[bool] = sa_orm.mapped_column(default=False) def is_valid(self) -> bool: @@ -605,7 +602,7 @@ def get_for( ).one_or_none() @classmethod - def all(cls, accounts: Query | Collection[Account]) -> list[Self]: # noqa: A003 + def all(cls, accounts: Query | Collection[Account]) -> list[Self]: """Return all AuthToken for the specified accounts.""" query = cls.query.join(AuthClient) if isinstance(accounts, QueryBaseClass): @@ -619,10 +616,10 @@ def all(cls, accounts: Query | Collection[Account]) -> list[Self]: # noqa: A003 else: count = len(accounts) if count == 1: - # Cast users into a list/tuple before accessing [0], as the source + # Extract the element as an iterable instead of using [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.account == tuple(accounts)[0]).all() + return query.filter(AuthToken.account == next(iter(accounts))).all() if count > 1: return query.filter( AuthToken.account_id.in_([u.id for u in accounts]) @@ -641,7 +638,6 @@ def all_for(cls, account: Account) -> Query[Self]: class AuthClientPermissions(BaseMixin[int, Account], 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( @@ -658,7 +654,7 @@ class AuthClientPermissions(BaseMixin[int, Account], Model): ) #: The permissions as a string of tokens access_permissions: Mapped[str] = sa_orm.mapped_column( - 'permissions', sa.UnicodeText, default='', nullable=False + 'permissions', sa.Unicode, default='', nullable=False ) # Only one assignment per account and client @@ -734,7 +730,7 @@ class AuthClientTeamPermissions(BaseMixin[int, Account], Model): ) #: The permissions as a string of tokens access_permissions: Mapped[str] = sa_orm.mapped_column( - 'permissions', sa.UnicodeText, default='', nullable=False + 'permissions', sa.Unicode, default='', nullable=False ) # Only one assignment per team and client diff --git a/funnel/models/base.py b/funnel/models/base.py index e94e1348d..fe3e10f0e 100644 --- a/funnel/models/base.py +++ b/funnel/models/base.py @@ -2,19 +2,22 @@ from __future__ import annotations -from typing import ClassVar +from typing import TYPE_CHECKING, Any, ClassVar import sqlalchemy as sa import sqlalchemy.exc as sa_exc import sqlalchemy.orm as sa_orm from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import Table +from sqlalchemy import Table, event from sqlalchemy.dialects import postgresql -from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.engine import Engine +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, declared_attr from sqlalchemy_utils import LocaleType, TimezoneType, TSVectorType from coaster.sqlalchemy import ( + AppenderQuery, BaseIdNameMixin, BaseMixin, BaseNameMixin, @@ -37,15 +40,18 @@ with_roles, ) +if TYPE_CHECKING: + from _typeshed.dbapi import DBAPIConnection -class Model(ModelBase, DeclarativeBase): + +class Model(AsyncAttrs, ModelBase, DeclarativeBase): """Base for all models.""" __table__: ClassVar[Table] __with_timezone__ = True -class GeonameModel(ModelBase, DeclarativeBase): +class GeonameModel(AsyncAttrs, ModelBase, DeclarativeBase): """Base for geoname models.""" __table__: ClassVar[Table] @@ -64,7 +70,18 @@ class GeonameModel(ModelBase, DeclarativeBase): GeonameModel.init_flask_sqlalchemy(db) +@event.listens_for(Engine, 'connect') +def _emit_engine_directives( + dbapi_connection: DBAPIConnection, _connection_record: Any +) -> None: + """Use UTC timezone on PostgreSQL.""" + cursor = dbapi_connection.cursor() + cursor.execute("SET TIME ZONE 'UTC';") + cursor.close() + + __all__ = [ + 'AppenderQuery', 'backref', 'BaseIdNameMixin', 'BaseMixin', @@ -78,6 +95,7 @@ class GeonameModel(ModelBase, DeclarativeBase): 'declared_attr', 'DynamicMapped', 'GeonameModel', + 'hybrid_method', 'hybrid_property', 'LocaleType', 'Mapped', diff --git a/funnel/models/comment.py b/funnel/models/comment.py index 07e188a35..77c3d3f6e 100644 --- a/funnel/models/comment.py +++ b/funnel/models/comment.py @@ -9,8 +9,14 @@ from werkzeug.utils import cached_property from baseframe import _, __ -from coaster.sqlalchemy import LazyRoleSet, RoleAccessProxy, StateManager, with_roles -from coaster.utils import LabeledEnum +from coaster.sqlalchemy import ( + LazyRoleSet, + RoleAccessProxy, + StateManager, + role_check, + with_roles, +) +from coaster.utils import LabeledEnum, NameTitle from .account import ( Account, @@ -37,7 +43,7 @@ __all__ = ['Comment', 'Commentset'] -# --- Constants ------------------------------------------------------------------------ +# MARK: Constants ---------------------------------------------------------------------- class COMMENTSET_STATE(LabeledEnum): # noqa: N801 @@ -51,13 +57,13 @@ class COMMENTSET_STATE(LabeledEnum): # noqa: N801 class COMMENT_STATE(LabeledEnum): # noqa: N801 # If you add any new state, you need to migrate the check constraint as well - SUBMITTED = (1, 'submitted', __("Submitted")) - SCREENED = (2, 'screened', __("Screened")) - HIDDEN = (3, 'hidden', __("Hidden")) - SPAM = (4, 'spam', __("Spam")) + SUBMITTED = (1, NameTitle('submitted', __("Submitted"))) + SCREENED = (2, NameTitle('screened', __("Screened"))) + HIDDEN = (3, NameTitle('hidden', __("Hidden"))) + SPAM = (4, NameTitle('spam', __("Spam"))) # Deleted state for when there are replies to be preserved - DELETED = (5, 'deleted', __("Deleted")) - VERIFIED = (6, 'verified', __("Verified")) + DELETED = (5, NameTitle('deleted', __("Deleted"))) + VERIFIED = (6, NameTitle('verified', __("Verified"))) PUBLIC = {SUBMITTED, VERIFIED} REMOVED = {SPAM, DELETED} @@ -78,7 +84,7 @@ class SET_TYPE: # noqa: N801 message_removed = MessageComposite(__("[removed]"), 'del') -# --- Models --------------------------------------------------------------------------- +# MARK: Models ------------------------------------------------------------------------- class Commentset(UuidMixin, BaseMixin[int, Account], Model): @@ -87,7 +93,9 @@ class Commentset(UuidMixin, BaseMixin[int, Account], Model): _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.SmallInteger, - StateManager.check_constraint('state', COMMENTSET_STATE, sa.SmallInteger), + StateManager.check_constraint( + 'state', COMMENTSET_STATE, sa.SmallInteger, name='commentset_state_check' + ), nullable=False, default=COMMENTSET_STATE.OPEN, ) @@ -204,14 +212,14 @@ def last_comment(self) -> Comment | None: with_roles(last_comment, read={'all'}, datasets={'primary'}) - def roles_for( - self, actor: Account | None = None, anchors: Sequence = () - ) -> LazyRoleSet: - roles = super().roles_for(actor, anchors) - parent_roles = self.parent.roles_for(actor, anchors) - if 'participant' in parent_roles or 'commenter' in parent_roles: - roles.add('parent_participant') - return roles + @role_check('parent_participant') + def has_parent_participant_role( + self, actor: Account | None, _anchors: Sequence[Any] = () + ) -> bool: + """Confirm if the actor is a participant in the parent object.""" + return (parent := self.parent) is not None and parent.roles_for(actor).has_any( + {'participant', 'commenter'} + ) @with_roles(call={'all'}) @state.requires(state.NOT_DISABLED) @@ -330,7 +338,10 @@ class Comment(UuidMixin, BaseMixin[int, Account], Model): _state: Mapped[int] = sa_orm.mapped_column( 'state', - StateManager.check_constraint('state', COMMENT_STATE, sa.Integer), + sa.SmallInteger, + StateManager.check_constraint( + 'state', COMMENT_STATE, sa.SmallInteger, name='comment_state_check' + ), default=COMMENT_STATE.SUBMITTED, nullable=False, ) @@ -410,7 +421,9 @@ def posted_by(self) -> Account | DuckTypeAccount: else ( removed_account if self.state.SPAM - else unknown_account if self._posted_by is None else self._posted_by + else unknown_account + if self._posted_by is None + else self._posted_by ) ) @@ -434,7 +447,9 @@ def message(self) -> MessageComposite | MarkdownCompositeBasic: return ( message_deleted if self.state.DELETED - else message_removed if self.state.SPAM else self._message + else message_removed + if self.state.SPAM + else self._message ) @message.inplace.setter @@ -495,16 +510,15 @@ def delete(self) -> None: if len(self.replies) > 0: self.posted_by = None self.message = '' + elif self.in_reply_to and self.in_reply_to.state.DELETED: + # If the comment this is replying to is deleted, ask it to reconsider + # removing itself + in_reply_to = self.in_reply_to + in_reply_to.replies.remove(self) + db.session.delete(self) + in_reply_to.delete() else: - if self.in_reply_to and self.in_reply_to.state.DELETED: - # If the comment this is replying to is deleted, ask it to reconsider - # removing itself - in_reply_to = self.in_reply_to - in_reply_to.replies.remove(self) - db.session.delete(self) - in_reply_to.delete() - else: - db.session.delete(self) + db.session.delete(self) @state.transition(None, state.SPAM) def mark_spam(self) -> None: @@ -521,12 +535,12 @@ def was_reviewed_by(self, account: Account) -> bool: CommentModeratorReport.reported_by == account, ).notempty() - def roles_for( - self, actor: Account | None = None, anchors: Sequence = () - ) -> LazyRoleSet: - roles = super().roles_for(actor, anchors) - roles.add('reader') - return roles + @role_check('reader') + def has_reader_role( + self, _actor: Account | None, _anchors: Sequence[Any] = () + ) -> bool: + """Everyone is always a reader (for now).""" + return True add_search_trigger(Comment, 'search_vector') diff --git a/funnel/models/contact_exchange.py b/funnel/models/contact_exchange.py index 92c7cbd28..cfa646a11 100644 --- a/funnel/models/contact_exchange.py +++ b/funnel/models/contact_exchange.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Collection, Sequence +from collections.abc import Collection from dataclasses import dataclass from datetime import date as date_type, datetime from itertools import groupby @@ -12,7 +12,7 @@ from pytz import BaseTzInfo, timezone from sqlalchemy.ext.associationproxy import association_proxy -from coaster.sqlalchemy import LazyRoleSet +from coaster.sqlalchemy import with_roles from coaster.utils import uuid_to_base58 from .account import Account @@ -38,7 +38,7 @@ class ProjectId: """Holder for minimal :class:`~funnel.models.project.Project` information.""" - id: int # noqa: A003 + id: int uuid: UUID uuid_b58: str title: str @@ -62,7 +62,9 @@ class ContactExchange(TimestampMixin, RoleMixin, Model): account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id', ondelete='CASCADE'), primary_key=True, default=None ) - account: Mapped[Account] = relationship(back_populates='scanned_contacts') + account: Mapped[Account] = with_roles( + relationship(back_populates='scanned_contacts'), grants={'owner'} + ) #: Participant whose contact was scanned ticket_participant_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('ticket_participant.id', ondelete='CASCADE'), @@ -70,8 +72,9 @@ class ContactExchange(TimestampMixin, RoleMixin, Model): default=None, index=True, ) - ticket_participant: Mapped[TicketParticipant] = relationship( - back_populates='scanned_contacts' + ticket_participant: Mapped[TicketParticipant] = with_roles( + relationship(back_populates='scanned_contacts'), + grants_via={'participant': {'subject'}}, ) #: Datetime at which the scan happened scanned_at: Mapped[datetime] = sa_orm.mapped_column( @@ -101,17 +104,6 @@ class ContactExchange(TimestampMixin, RoleMixin, Model): 'subject': {'read': {'account', 'ticket_participant', 'scanned_at'}}, } - def roles_for( - self, actor: Account | None = None, anchors: Sequence = () - ) -> LazyRoleSet: - roles = super().roles_for(actor, anchors) - if actor is not None: - if actor == self.account: - roles.add('owner') - if actor == self.ticket_participant.participant: - roles.add('subject') - return roles - @classmethod def migrate_account(cls, old_account: Account, new_account: Account) -> None: """Migrate one account's data to another when merging accounts.""" @@ -219,7 +211,7 @@ def grouped_counts_for( # We don't do it here, but this can easily be converted into a dictionary of # `{project: dates}` using `dict(result)` - groups = [ + return [ ( k, [ @@ -245,8 +237,6 @@ def grouped_counts_for( ) ] - return groups - @classmethod def contacts_for_project_and_date( cls, diff --git a/funnel/models/draft.py b/funnel/models/draft.py index bc9eaeedb..c08e15b49 100644 --- a/funnel/models/draft.py +++ b/funnel/models/draft.py @@ -17,9 +17,9 @@ class Draft(NoIdMixin, Model): __tablename__ = 'draft' - table: Mapped[types.text] = sa_orm.mapped_column(primary_key=True) + table: Mapped[types.Text] = sa_orm.mapped_column(primary_key=True) table_row_id: Mapped[UUID] = sa_orm.mapped_column(primary_key=True) - body: Mapped[types.jsonb_dict | None] # Optional only when instance is new + body: Mapped[types.JsonbDict | None] # Optional only when instance is new revision: Mapped[UUID | None] @property diff --git a/funnel/models/email_address.py b/funnel/models/email_address.py index 163084561..6c31e6e6e 100644 --- a/funnel/models/email_address.py +++ b/funnel/models/email_address.py @@ -231,10 +231,11 @@ class methods, depending on whether the email address is linked to an owner or n #: Does this email address work? Records last known delivery state _delivery_state: Mapped[int] = sa_orm.mapped_column( 'delivery_state', + sa.SmallInteger, StateManager.check_constraint( 'delivery_state', EMAIL_DELIVERY_STATE, - sa.Integer, + sa.SmallInteger, name='email_address_delivery_state_check', ), nullable=False, @@ -387,7 +388,7 @@ def __init__(self, email: str) -> None: raise ValueError("Value is not an email address") from exc self.email = email # email_canonical is set by `email`'s validator - assert self.email_canonical is not None # nosec + assert self.email_canonical is not None # noqa: S101 self.blake2b160_canonical = email_blake2b160_hash(self.email_canonical) def is_exclusive(self) -> bool: @@ -502,7 +503,7 @@ def get_filter( def get( cls, email: str, - ) -> EmailAddress | None: ... + ) -> Self | None: ... @overload @classmethod @@ -510,7 +511,7 @@ def get( cls, *, blake2b160: bytes, - ) -> EmailAddress | None: ... + ) -> Self | None: ... @overload @classmethod @@ -518,7 +519,7 @@ def get( cls, *, email_hash: str, - ) -> EmailAddress | None: ... + ) -> Self | None: ... @classmethod def get( @@ -527,7 +528,7 @@ def get( *, blake2b160: bytes | None = None, email_hash: str | None = None, - ) -> EmailAddress | None: + ) -> Self | None: """ Get an :class:`EmailAddress` instance by email address or its hash. @@ -557,7 +558,7 @@ def get_canonical(cls, email: str, is_blocked: bool | None = None) -> Query[Self return query @classmethod - def _get_existing(cls, email: str) -> EmailAddress | None: + def _get_existing(cls, email: str) -> Self | None: """ Get an existing :class:`EmailAddress` instance. @@ -567,10 +568,10 @@ def _get_existing(cls, email: str) -> EmailAddress | None: return None if cls.get_canonical(email, is_blocked=True).notempty(): raise EmailAddressBlockedError("Email address is blocked") - return EmailAddress.get(email) + return cls.get(email) @classmethod - def add(cls, email: str) -> EmailAddress: + def add(cls, email: str) -> Self: """ Create a new :class:`EmailAddress` after validation. @@ -586,12 +587,12 @@ def add(cls, email: str) -> EmailAddress: if not existing.email: existing.email = email return existing - new_email = EmailAddress(email) + new_email = cls(email) db.session.add(new_email) return new_email @classmethod - def add_for(cls, owner: Account | None, email: str) -> EmailAddress: + def add_for(cls, owner: Account | None, email: str) -> Self: """ Create a new :class:`EmailAddress` after validation. @@ -605,7 +606,7 @@ def add_for(cls, owner: Account | None, email: str) -> EmailAddress: # No exclusive lock found? Let it be used then existing.email = email return existing - new_email = EmailAddress(email) + new_email = cls(email) db.session.add(new_email) return new_email @@ -672,12 +673,11 @@ def validate_for( return 'taken' # There is an existing but it's available for this owner. Any other concerns? - if new: - # Caller is asking to confirm this is not already belonging to this owner - if existing.is_exclusive(): - # It's in an exclusive relationship, and we're already determined it's - # available to this owner, so it must be exclusive to them - return 'not_new' + if new and existing.is_exclusive(): + # Caller is asking to confirm this is not already belonging to this owner: + # It's in an exclusive relationship, and we're already determined it's + # available to this owner, so it must be exclusive to them + return 'not_new' if existing.delivery_state.SOFT_FAIL: return 'soft_fail' if existing.delivery_state.HARD_FAIL: @@ -780,11 +780,10 @@ def email(self, __value: str | None) -> None: else: self.email_address = None + elif __value is not None: + self.email_address = EmailAddress.add(__value) else: - if __value is not None: - self.email_address = EmailAddress.add(__value) - else: - self.email_address = None + self.email_address = None @property def email_address_reference_is_active(self) -> bool: @@ -916,12 +915,11 @@ def _email_address_mixin_set_validator( old_value: EmailAddress | None, _initiator: Any, ) -> None: - if value != old_value and target.__email_for__: - if value is not None: - if value.is_blocked: - raise EmailAddressBlockedError("This email address has been blocked") - if not value.is_available_for(getattr(target, target.__email_for__)): - raise EmailAddressInUseError("This email address it not available") + if value != old_value and target.__email_for__ and value is not None: + if value.is_blocked: + raise EmailAddressBlockedError("This email address has been blocked") + if not value.is_available_for(getattr(target, target.__email_for__)): + raise EmailAddressInUseError("This email address it not available") @event.listens_for(OptionalEmailAddressMixin, 'mapper_configured', propagate=True) diff --git a/funnel/models/geoname.py b/funnel/models/geoname.py index ff07182d2..da7eef54d 100644 --- a/funnel/models/geoname.py +++ b/funnel/models/geoname.py @@ -61,29 +61,29 @@ class GeoCountryInfo(BaseNameMixin, GeonameModel): primaryjoin=lambda: GeoCountryInfo.id == sa_orm.foreign(GeoName.id), back_populates='has_country', ) - iso_alpha2: Mapped[types.char2 | None] = sa_orm.mapped_column( + iso_alpha2: Mapped[types.Char2 | None] = sa_orm.mapped_column( sa.CHAR(2), unique=True ) - iso_alpha3: Mapped[types.char3 | None] = sa_orm.mapped_column(unique=True) + iso_alpha3: Mapped[types.Char3 | None] = sa_orm.mapped_column(unique=True) iso_numeric: Mapped[int | None] - fips_code: Mapped[types.str3 | 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] + 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] + phone: Mapped[types.Str16 | None] + postal_code_format: Mapped[types.Unicode | None] + postal_code_regex: Mapped[types.Unicode | None] languages: Mapped[list[str] | None] = sa_orm.mapped_column( ARRAY(sa.Unicode, dimensions=1) ) neighbours: Mapped[list[str] | None] = sa_orm.mapped_column( ARRAY(sa.CHAR(2), dimensions=1) ) - equivalent_fips_code: Mapped[types.str3] + equivalent_fips_code: Mapped[types.Str3] __table_args__ = ( sa.Index( @@ -258,13 +258,17 @@ def short_title(self) -> str: return ( self.admin1code.title if self.admin1code - else self.admin1_ref.title if self.admin1_ref else '' + else self.admin1_ref.title + if self.admin1_ref + else '' ) or '' if self.has_admin2code: return ( self.admin2code.title if self.admin2code - else self.admin2_ref.title if self.admin2_ref else '' + else self.admin2_ref.title + if self.admin2_ref + else '' ) or '' return self.ascii_title or self.title @@ -273,10 +277,7 @@ def picker_title(self) -> str: """Return a disambiguation title for this geoname record.""" title = self.use_title country = self.country_id - if country == 'US': - state = self.admin1 - else: - state = None + state = self.admin1 if country == 'US' else None suffix = None if (self.fclass, self.fcode) == ('L', 'CONT'): @@ -288,10 +289,7 @@ def picker_title(self) -> str: country = None state = None elif self.has_admin1code: - if country in ('CA', 'CN', 'AF'): - suffix = 'province' - else: - suffix = 'state' + suffix = 'province' if country in ('CA', 'CN', 'AF') else 'state' state = None elif self.has_admin2code: if country == 'US': diff --git a/funnel/models/helpers.py b/funnel/models/helpers.py index ec8fd9efb..4ace8bf80 100644 --- a/funnel/models/helpers.py +++ b/funnel/models/helpers.py @@ -8,7 +8,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass from textwrap import dedent -from typing import Any, ClassVar, TypeVar, get_type_hints +from typing import Any, ClassVar, Self, TypeVar, get_type_hints from better_profanity import profanity from furl import furl @@ -65,6 +65,7 @@ 'brand', 'brands', 'by', + 'calendar', 'client', 'clients', 'comments', @@ -80,6 +81,9 @@ 'embed', 'event', 'events', + 'follow', + 'followers', + 'following', 'ftp', 'funnel', 'funnels', @@ -224,6 +228,7 @@ def add_to_class(cls: type, name: str | None = None) -> Callable[[T], T]: def new_method(self, *args): pass + @add_to_class(ExistingClass, 'new_property') @property def existing_class_new_property(self): @@ -264,13 +269,12 @@ def reopen(cls: ReopenedType) -> Callable[[type], ReopenedType]: @reopen(ExistingClass) class __ExistingClass: @property - def new_property(self): - ... + def new_property(self): ... This is equivalent to:: - def new_property(self): - ... + def new_property(self): ... + ExistingClass.new_property = property(new_property) @@ -433,9 +437,11 @@ class MyModel(Model): ... search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( - 'name', 'title', *indexed_columns, + 'name', + 'title', + *indexed_columns, weights={'name': 'A', 'title': 'B'}, - regconfig='english' + regconfig='english', ), nullable=False, deferred=True, @@ -443,12 +449,11 @@ class MyModel(Model): __table_args__ = ( sa.Index( - 'ix_mymodel_search_vector', - 'search_vector', - postgresql_using='gin' + 'ix_mymodel_search_vector', 'search_vector', postgresql_using='gin' ), ) + add_search_trigger(MyModel, 'search_vector') To extract the SQL required in a migration: @@ -500,7 +505,7 @@ class MyModel(Model): CREATE TRIGGER {trigger_name} BEFORE INSERT OR UPDATE OF {source_columns} ON {table_name} FOR EACH ROW EXECUTE PROCEDURE {function_name}(); - '''.format( # nosec + '''.format( function_name=pgquote(function_name), column_name=pgquote(column_name), trigger_expr=trigger_expr, @@ -511,7 +516,7 @@ class MyModel(Model): ) update_statement = ( - f'UPDATE {pgquote(model.__tablename__)}' # nosec + f'UPDATE {pgquote(model.__tablename__)}' # noqa: S608 f' SET {pgquote(column_name)} = {update_expr};' ) @@ -638,7 +643,7 @@ def __init__(self, text: str | None, html: str | None = None) -> None: def __composite_values__(self) -> tuple[str | None, str | None]: """Return composite values for SQLAlchemy.""" - return (self._text, self._html) + return self._text, self._html # Return a string representation of the text (see class decorator) def __str__(self) -> str: @@ -687,7 +692,7 @@ def __json__(self) -> dict[str, str | None]: """Return JSON-compatible rendering of composite.""" return {'text': self._text, 'html': self._html} - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: """Compare for equality.""" return ( isinstance(other, self.__class__) @@ -695,7 +700,7 @@ def __eq__(self, other: Any) -> bool: or self._text == other ) - def __ne__(self, other: Any) -> bool: + def __ne__(self, other: object) -> bool: """Compare for inequality.""" return not self.__eq__(other) @@ -706,7 +711,7 @@ def __ne__(self, other: Any) -> bool: def __getstate__(self) -> tuple[str | None, str | None]: """Get state for pickling.""" # Return state for pickling - return (self._text, self._html) + return self._text, self._html def __setstate__(self, state: tuple[str | None, str | None]) -> None: """Set state from pickle.""" @@ -719,7 +724,7 @@ def __bool__(self) -> bool: return bool(self._text) @classmethod - def coerce(cls: type[_MC], key: str, value: Any) -> _MC: + def coerce(cls, _key: str, value: Any) -> Self: """Allow a composite column to be assigned a string value.""" return cls(value) diff --git a/funnel/models/login_session.py b/funnel/models/login_session.py index ea2b62a71..885658821 100644 --- a/funnel/models/login_session.py +++ b/funnel/models/login_session.py @@ -8,6 +8,7 @@ from coaster.utils import utcnow from ..signals import session_revoked +from . import types from .account import Account from .base import ( BaseMixin, @@ -40,15 +41,15 @@ def __init__(self, login_session: LoginSession, *args: Any) -> None: class LoginSessionExpiredError(LoginSessionError): - """This user session has expired and cannot be marked as currently active.""" + """User session has expired and cannot be marked as currently active.""" class LoginSessionRevokedError(LoginSessionError): - """This user session has been revoked and cannot be marked as currently active.""" + """User session has been revoked and cannot be marked as currently active.""" class LoginSessionInactiveUserError(LoginSessionError): - """This user is not in ACTIVE state and cannot have a currently active session.""" + """User is not in ACTIVE state and cannot have a currently active session.""" LOGIN_SESSION_VALIDITY_PERIOD = timedelta(days=365) @@ -106,7 +107,9 @@ class LoginSession(UuidMixin, BaseMixin[int, Account], Model): #: User's network, from IP address geoip_asn: Mapped[int | None] = sa_orm.mapped_column() #: User agent - user_agent: Mapped[str] = sa_orm.mapped_column(sa.UnicodeText, nullable=False) + user_agent: Mapped[str] = sa_orm.mapped_column(sa.Unicode, nullable=False) + #: User agent client hints + user_agent_client_hints: Mapped[types.Jsonb | None] #: The login service that was used to make this session login_service: Mapped[str | None] = sa_orm.mapped_column() diff --git a/funnel/models/mailer.py b/funnel/models/mailer.py index 0112e46c2..3d87c9ce7 100644 --- a/funnel/models/mailer.py +++ b/funnel/models/mailer.py @@ -19,6 +19,7 @@ from .. import __ from ..utils.markdown import MarkdownString, markdown_mailer from ..utils.mustache import mustache_md +from . import types from .account import Account from .base import ( BaseNameMixin, @@ -32,7 +33,6 @@ sa_orm, ) from .helpers import IntTitle -from .types import jsonb __all__ = [ 'MailerState', @@ -244,7 +244,7 @@ class MailerRecipient(BaseScopedIdMixin[int, Account], Model): sa.String(32), nullable=False, index=True ) - data: Mapped[jsonb] = sa_orm.mapped_column() + data: Mapped[types.Jsonb] = sa_orm.mapped_column() is_sent: Mapped[bool] = sa_orm.mapped_column(default=False) diff --git a/funnel/models/membership_mixin.py b/funnel/models/membership_mixin.py index 8977aca0d..85f0f6ce5 100644 --- a/funnel/models/membership_mixin.py +++ b/funnel/models/membership_mixin.py @@ -40,7 +40,7 @@ 'MembershipRecordTypeError', ] -# --- Typing --------------------------------------------------------------------------- +# MARK: Typing ------------------------------------------------------------------------- MembershipType = TypeVar('MembershipType', bound='ImmutableMembershipMixin') @@ -76,7 +76,7 @@ def parent_scoped_reorder_query_filter( ) -# --- Enum ----------------------------------------------------------------------------- +# MARK: Enum --------------------------------------------------------------------------- class MembershipRecordTypeEnum(IntTitle, ReprEnum): @@ -92,10 +92,10 @@ class MembershipRecordTypeEnum(IntTitle, ReprEnum): AMEND = 4, __("Amend") #: A migrate record says this used to be some other form of membership and has been #: created due to a technical change in the product - # Forthcoming: MIGRATE = 5, __("Migrate") + MIGRATE = 5, __("Migrate") -# --- Exceptions ----------------------------------------------------------------------- +# MARK: Exceptions --------------------------------------------------------------------- class MembershipError(Exception): @@ -110,7 +110,7 @@ class MembershipRecordTypeError(MembershipError): """Membership record type is invalid.""" -# --- Classes -------------------------------------------------------------------------- +# MARK: Classes ------------------------------------------------------------------------ @declarative_mixin @@ -158,7 +158,7 @@ def user(cls) -> Mapped[Account]: """Legacy alias for member in this membership record.""" return sa_orm.synonym('member') - __table_args__: tuple # pyright: ignore[reportGeneralTypeIssues] + __table_args__: tuple # pyright: ignore[reportRedeclaration] @declared_attr.directive # type: ignore[no-redef] @classmethod @@ -222,8 +222,9 @@ def __table_args__(cls) -> tuple: record_type: Mapped[int] = with_roles( immutable( sa_orm.mapped_column( + sa.SmallInteger, StateManager.check_constraint( - 'record_type', MembershipRecordTypeEnum, sa.Integer + 'record_type', MembershipRecordTypeEnum, sa.SmallInteger ), default=MembershipRecordTypeEnum.DIRECT_ADD, nullable=False, @@ -309,6 +310,13 @@ def is_amendment(self) -> bool: with_roles(is_amendment, read={'member', 'editor'}) + @hybrid_property + def is_migrated(self) -> bool: + """Test if membership record is migrated data.""" + return self.record_type == MembershipRecordTypeEnum.MIGRATE + + with_roles(is_migrated, read={'member', 'editor'}) + def __repr__(self) -> str: # pylint: disable=using-constant-test return ( @@ -346,16 +354,21 @@ def replace(self, actor: Account, _accept: bool = False, **data: Any) -> Self: # Perform sanity check. If nothing changed, just return self has_changes = False - if self.record_type == MembershipRecordTypeEnum.INVITE and _accept: + if ( + self.record_type == MembershipRecordTypeEnum.INVITE and _accept + ) or self.record_type == MembershipRecordTypeEnum.MIGRATE: # If the existing record is an INVITE and this is an ACCEPT, we have - # a record change even if no data changed + # a record change even if no data changed; + # If this was a migrated record, replace it with an AMEND record even if no + # data changed has_changes = True else: - # If it's not an ACCEPT, are the supplied data different from existing? + # If it's not an ACCEPT, is the supplied data different from existing? self._local_data_only = True for column_name, column_value in data.items(): if column_value != getattr(self, column_name): has_changes = True + break del self._local_data_only if not has_changes: # Nothing is changing. This is probably a form submit with no changes. @@ -370,7 +383,7 @@ def replace(self, actor: Account, _accept: bool = False, **data: Any) -> Self: new = self.copy_template(parent_id=self.parent_id, granted_by=actor) del self._local_data_only - # if existing record type is INVITE, then ACCEPT or amend as new INVITE + # if existing record type is INVITE, then ACCEPT or amend as new INVITE, # else replace it with AMEND if self.record_type == MembershipRecordTypeEnum.INVITE: if _accept: @@ -524,7 +537,7 @@ class ReorderMembershipMixin(ImmutableMembershipMixin, ReorderMixin): #: on `seq` being mutable in a future iteration. seq: Mapped[int] = sa_orm.mapped_column(nullable=False) - __table_args__: tuple # pyright: ignore[reportGeneralTypeIssues] + __table_args__: tuple # pyright: ignore[reportRedeclaration] @declared_attr.directive # type: ignore[no-redef] @classmethod @@ -680,11 +693,13 @@ def __setattr__(self, attr: str, value: Any) -> None: ) self._new[attr] = value - def __enter__(self) -> AmendMembership: + def __enter__(self) -> Self: """Enter a `with` context.""" return self - def __exit__(self, exc_type: Any, _exc_value: Any, _traceback: Any) -> None: + def __exit__( + self, exc_type: object, _exc_value: object, _traceback: object + ) -> None: """Exit a `with` context and replace the membership record.""" if exc_type is None: object.__setattr__( @@ -701,8 +716,6 @@ def commit(self) -> MembershipType: def _confirm_enumerated_mixins(_mapper: Any, cls: type[Account]) -> None: """Confirm that the membership collection attributes actually exist.""" expected_class = ImmutableMembershipMixin - if issubclass(cls, Account): - expected_class = ImmutableMembershipMixin for source in ( cls.__active_membership_attrs__, cls.__noninvite_membership_attrs__, diff --git a/funnel/models/moderation.py b/funnel/models/moderation.py index ae5c6220a..5b254465a 100644 --- a/funnel/models/moderation.py +++ b/funnel/models/moderation.py @@ -8,7 +8,7 @@ from baseframe import __ from coaster.sqlalchemy import StateManager, with_roles -from coaster.utils import LabeledEnum +from coaster.utils import LabeledEnum, NameTitle from .account import Account from .base import ( @@ -29,8 +29,8 @@ class MODERATOR_REPORT_TYPE(LabeledEnum): # noqa: N801 - OK = (1, 'ok', __("Not spam")) - SPAM = (2, 'spam', __("Spam")) + OK = (1, NameTitle('ok', __("Not spam"))) + SPAM = (2, NameTitle('spam', __("Spam"))) class CommentModeratorReport(UuidMixin, BaseMixin[UUID, Account], Model): diff --git a/funnel/models/notification.py b/funnel/models/notification.py index 0d953817d..cc72ffcc7 100644 --- a/funnel/models/notification.py +++ b/funnel/models/notification.py @@ -50,7 +50,7 @@ now queues a third series of background workers, for each of the supported transports if at least one recipient in that batch wants to use that transport. -6. A separate render view class named RenderNewUpdateNotification contains methods named +6. A separate render view class named RenderNotification contains methods named like `web`, `email`, `sms` and others. These are expected to return a rendered message. The `web` render is used for the notification feed page on the website. @@ -83,6 +83,7 @@ from __future__ import annotations from collections.abc import Callable, Generator, Sequence +from contextlib import suppress from dataclasses import dataclass from datetime import datetime from enum import ReprEnum @@ -151,7 +152,7 @@ 'notification_web_types', ] -# --- Typing --------------------------------------------------------------------------- +# MARK: Typing ------------------------------------------------------------------------- # Document generic type _D = TypeVar('_D', bound=ModelUuidProtocol) @@ -160,7 +161,7 @@ # Type of None (required to detect Optional) NoneType = type(None) -# --- Registries ----------------------------------------------------------------------- +# MARK: Registries --------------------------------------------------------------------- #: Registry of Notification subclasses for user preferences, automatically populated. #: Inactive types and types that shadow other types are excluded from this registry @@ -180,14 +181,14 @@ class NotificationCategory: #: Registry of notification categories notification_categories: SimpleNamespace = SimpleNamespace( - none=NotificationCategory(0, __("Uncategorized"), lambda user: False), - account=NotificationCategory(1, __("My account"), lambda user: True), + none=NotificationCategory(0, __("Uncategorized"), lambda _user: False), + account=NotificationCategory(1, __("My account"), lambda _user: True), subscriptions=NotificationCategory( - 2, __("My subscriptions and billing"), lambda user: False + 2, __("My subscriptions and billing"), lambda _user: False ), participant=NotificationCategory( 3, - __("Projects I am participating in"), + __("My activities"), # Criteria: User has registered or proposed lambda user: ( db.session.query(user.rsvps.exists()).scalar() @@ -196,7 +197,7 @@ class NotificationCategory: ), project_crew=NotificationCategory( 4, - __("Projects I am a crew member in"), + __("My projects"), # Criteria: user has ever been a project crew member lambda user: db.session.query( user.projects_as_crew_memberships.exists() @@ -204,7 +205,7 @@ class NotificationCategory: ), account_admin=NotificationCategory( 5, - __("Accounts I manage"), + __("My shared accounts"), # Criteria: user has ever been an organization admin lambda user: db.session.query( user.organization_admin_memberships.exists() @@ -219,7 +220,7 @@ class NotificationCategory: ) -# --- Flags ---------------------------------------------------------------------------- +# MARK: Flags -------------------------------------------------------------------------- class SmsStatusEnum(IntTitle, ReprEnum): @@ -232,7 +233,7 @@ class SmsStatusEnum(IntTitle, ReprEnum): UNKNOWN = 5, __("Unknown") -# --- Legacy models -------------------------------------------------------------------- +# MARK: Legacy models ------------------------------------------------------------------ class SmsMessage(PhoneNumberMixin, BaseMixin[int, Account], Model): @@ -244,7 +245,7 @@ class SmsMessage(PhoneNumberMixin, BaseMixin[int, Account], Model): phone_number_reference_is_active: bool = False transactionid: Mapped[str | None] = immutable( - sa_orm.mapped_column(sa.UnicodeText, unique=True, nullable=True) + sa_orm.mapped_column(sa.Unicode, unique=True, nullable=True) ) # The message itself message: Mapped[str] = immutable( @@ -257,9 +258,7 @@ class SmsMessage(PhoneNumberMixin, BaseMixin[int, Account], Model): status_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) - fail_reason: Mapped[str | None] = sa_orm.mapped_column( - sa.UnicodeText, nullable=True - ) + fail_reason: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True) def __init__(self, **kwargs: Any) -> None: phone = kwargs.pop('phone', None) @@ -268,7 +267,7 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) -# --- Notification models -------------------------------------------------------------- +# MARK: Notification models ------------------------------------------------------------ class NotificationType(Generic[_D, _F], Protocol): @@ -277,13 +276,13 @@ class NotificationType(Generic[_D, _F], Protocol): preference_context: ClassVar[Any] for_private_recipient: bool - type: str # noqa: A003 + type_: str eventid: UUID - id: UUID # noqa: A003 + id: UUID eventid_b58: str document: _D document_uuid: UUID - fragment: _F | None + fragment: _F fragment_uuid: UUID | None created_at: datetime created_by_id: int | None @@ -322,7 +321,7 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): ) #: Notification id - id: Mapped[UUID] = immutable( # noqa: A003 + id: Mapped[UUID] = immutable( sa_orm.mapped_column( postgresql.UUID, primary_key=True, @@ -344,19 +343,22 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): #: another type (auto-populated from subclass's `shadow=` parameter) pref_type: ClassVar[str] = '' - #: Document model, must be specified in subclasses + #: Document model, auto-populated from generic arg to Notification base class document_model: ClassVar[type[ModelUuidProtocol]] #: SQL table name for document type, auto-populated from the document model document_type: ClassVar[str] - #: Fragment model, optional for subclasses - fragment_model: ClassVar[type[ModelUuidProtocol] | None] = None + #: Fragment model, auto-populated from generic arg to Notification base class + fragment_model: ClassVar[type[ModelUuidProtocol] | None] #: SQL table name for fragment type, auto-populated from the fragment model fragment_type: ClassVar[str | None] #: Roles to send notifications to. Roles must be in order of priority for situations - #: where a user has more than one role on the document. - roles: ClassVar[Sequence[str]] = [] + #: where a user has more than one role on the document. The notification can + #: customize target roles based on the document or fragment's properties + @property + def dispatch_roles(self) -> Sequence[str]: + return [] #: Exclude triggering actor from receiving notifications? Subclasses may override exclude_actor: ClassVar[bool] = False @@ -436,7 +438,7 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): 'fragment_type', 'document', 'fragment', - 'type', + 'type_', 'user', }, 'related': { @@ -446,7 +448,7 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): 'fragment_type', 'document', 'fragment', - 'type', + 'type_', }, } @@ -574,7 +576,7 @@ def __init__( @property def identity(self) -> tuple[UUID, UUID]: """Primary key of this object.""" - return (self.eventid, self.id) + return self.eventid, self.id @hybrid_property def eventid_b58(self) -> str: @@ -609,18 +611,19 @@ def document(self) -> _D: ) @cached_property - def fragment(self) -> _F | None: + def fragment(self) -> _F: """ Retrieve the fragment within a document referenced by this Notification, if any. This assumes the underlying object won't disappear, as there is no SQL foreign key constraint enforcing a link. """ - if self.fragment_uuid and self.fragment_model: + if self.fragment_model is not None and self.fragment_uuid is not None: return cast( _F, self.fragment_model.query.filter_by(uuid=self.fragment_uuid).one() ) - return None + # TypeVar _F may be typed `None` in a subclass, but we can't type it so here + return None # type: ignore[return-value] @classmethod def renderer(cls, view: type[T]) -> type[T]: @@ -632,9 +635,9 @@ def renderer(cls, view: type[T]) -> type[T]: from ..models import MyNotificationType from .views import NotificationView + @MyNotificationType.renderer - class MyNotificationView(NotificationView): - ... + class MyNotificationView(NotificationView): ... """ if cls.cls_type in cls.renderers: raise TypeError( @@ -667,7 +670,7 @@ def dispatch(self) -> Generator[NotificationRecipient, None, None]: should override this method. """ for account, role in self.role_provider_obj.actors_with( - self.roles, with_role=True + self.dispatch_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 @@ -706,11 +709,6 @@ def dispatch(self) -> Generator[NotificationRecipient, None, None]: 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 - # used for type-hinting - type: Mapped[str] = sa_orm.synonym('type_') # noqa: A003 - class PreviewNotification(NotificationType): """ @@ -719,8 +717,7 @@ class PreviewNotification(NotificationType): To be used with :class:`NotificationFor`:: NotificationFor( - PreviewNotification(NotificationType, document, fragment, actor), - recipient + PreviewNotification(NotificationType, document, fragment, actor), recipient ) """ @@ -737,7 +734,7 @@ def __init__( # pylint: disable=super-init-not-called self.id = uuid4() self.eventid_b58 = uuid_to_base58(self.eventid) self.cls = cls - self.type = cls.cls_type + self.type_ = cls.cls_type self.for_private_recipient = cls.for_private_recipient self.document = document self.document_uuid = document.uuid @@ -760,7 +757,7 @@ class NotificationRecipientProtoMixin: @cached_property def notification_type(self) -> str: """Return the notification type identifier.""" - return self.notification.type + return self.notification.type_ with_roles(notification_type, read={'owner'}) @@ -797,10 +794,8 @@ def is_not_deleted(self, revoke: bool = False) -> bool: :param bool revoke: Mark the notification as revoked if document or fragment is missing """ - try: + with suppress(NoResultFound): return bool(self.fragment and self.document or self.document) - except NoResultFound: - pass if revoke: self.is_revoked = True # Do not set `self.rollupid` because this is not a rollup @@ -928,12 +923,12 @@ class NotificationRecipient(NoIdMixin, NotificationRecipientProtoMixin, Model): }, } - # --- User notification properties ------------------------------------------------- + # MARK: User notification properties ----------------------------------------------- @property def identity(self) -> tuple[int, UUID]: """Primary key of this object.""" - return (self.recipient_id, self.eventid) + return self.recipient_id, self.eventid @hybrid_property def eventid_b58(self) -> str: @@ -995,7 +990,7 @@ def _is_revoked_expression(cls) -> sa.ColumnElement[bool]: with_roles(is_revoked, rw={'owner'}) - # --- Dispatch helper methods ------------------------------------------------------ + # MARK: Dispatch helper methods ---------------------------------------------------- def recipient_preferences(self) -> NotificationPreferences: """Return the account's notification preferences for this notification type.""" @@ -1080,7 +1075,7 @@ def rollup_previous(self) -> None: # Same user NotificationRecipient.recipient_id == self.recipient_id, # Same type of notification - Notification.type == self.notification.type, + Notification.type_ == self.notification.type_, # Same document Notification.document_uuid == self.notification.document_uuid, # Same reason for receiving notification as earlier instance (same role) @@ -1120,7 +1115,7 @@ def rollup_previous(self) -> None: # Not ourselves NotificationRecipient.eventid != self.eventid, # Same type of notification - Notification.type == self.notification.type, + Notification.type_ == self.notification.type_, # Same document Notification.document_uuid == self.notification.document_uuid, # Same role as earlier notification, @@ -1171,7 +1166,7 @@ def web_notifications_for( ) -> Query[Self]: """Return web notifications for a user, optionally returning unread-only.""" query = cls.query.join(Notification).filter( - Notification.type.in_(notification_web_types), + Notification.type_.in_(notification_web_types), cls.recipient == user, cls.revoked_at.is_(None), ) @@ -1185,7 +1180,7 @@ def unread_count_for(cls, user: Account) -> int: return ( NotificationRecipient.query.join(Notification) .filter( - Notification.type.in_(notification_web_types), + Notification.type_.in_(notification_web_types), NotificationRecipient.recipient == user, NotificationRecipient.read_at.is_(None), NotificationRecipient.revoked_at.is_(None), @@ -1239,7 +1234,7 @@ def role(self) -> str | None: """User's primary matching role for this notification.""" if self.document and self.recipient: roles = self.document.roles_for(self.recipient) - for role in self.notification.roles: + for role in self.notification.dispatch_roles: if role in roles: return role return None @@ -1253,7 +1248,7 @@ def rolledup_fragments(self) -> Query | None: ) -# --- Notification preferences --------------------------------------------------------- +# MARK: Notification preferences ------------------------------------------------------- class NotificationPreferences(BaseMixin[int, Account], Model): @@ -1275,8 +1270,9 @@ class NotificationPreferences(BaseMixin[int, Account], Model): grants={'owner'}, ) - # Notification type, corresponding to Notification.type (a class attribute there) - # notification_type = '' holds the veto switch to disable a transport entirely + # Notification type, corresponding to `Notification.cls_type` (a class attribute + # there). `notification_type = ''` holds the veto switch to disable a transport + # entirely notification_type: Mapped[str] = immutable(sa_orm.mapped_column()) by_email: Mapped[bool] = with_roles(sa_orm.mapped_column(), rw={'owner'}) @@ -1378,7 +1374,7 @@ def migrate_account(cls, old_account: Account, new_account: Account) -> None: ) @sa_orm.validates('notification_type') - def _valid_notification_type(self, key: str, value: str | None) -> 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: @@ -1386,14 +1382,14 @@ def _valid_notification_type(self, key: str, value: str | None) -> str: return value -# --- Signal handlers ------------------------------------------------------------------ +# MARK: Signal handlers ---------------------------------------------------------------- auto_init_default(Notification.eventid) @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 3ba50ba9b..a3a501539 100644 --- a/funnel/models/notification_types.py +++ b/funnel/models/notification_types.py @@ -1,5 +1,10 @@ """Notification types.""" +# Pyright complains that a property in the base class (for `roles`) has become a +# classvar in the subclass. Mypy does not. Silence Pyright here + +# pyright: reportAssignmentType=false + from __future__ import annotations from baseframe import __ @@ -18,22 +23,23 @@ __all__ = [ 'AccountPasswordNotification', - 'NewUpdateNotification', + 'ProjectUpdateNotification', 'CommentReportReceivedNotification', 'CommentReplyNotification', 'NewCommentNotification', - 'ProjectCrewMembershipNotification', - 'ProjectCrewMembershipRevokedNotification', + 'ProjectCrewNotification', + 'ProjectCrewRevokedNotification', 'ProposalReceivedNotification', 'ProposalSubmittedNotification', 'RegistrationCancellationNotification', 'RegistrationConfirmationNotification', 'ProjectStartingNotification', - 'OrganizationAdminMembershipNotification', - 'OrganizationAdminMembershipRevokedNotification', + 'ProjectTomorrowNotification', + 'AccountAdminNotification', + 'AccountAdminRevokedNotification', ] -# --- Protocol and Mixin classes ------------------------------------------------------- +# MARK: Protocol and Mixin classes ----------------------------------------------------- class DocumentHasProject: @@ -63,7 +69,7 @@ def preference_context(self) -> Account: return self.document # type: ignore[attr-defined] -# --- Account notifications ------------------------------------------------------------ +# MARK: Account notifications ---------------------------------------------------------- class AccountPasswordNotification( @@ -73,14 +79,29 @@ class AccountPasswordNotification( category = notification_categories.account title = __("When my account password changes") - description = __("For your safety, in case this was not authorized") + description = __("For your attention, in case this was not authorized") exclude_actor = False - roles = ['owner'] + dispatch_roles = ['owner'] for_private_recipient = True -# --- Project participant notifications ------------------------------------------------ +class FollowerNotification( + DocumentIsAccount, Notification[Account, AccountMembership], type='follower' +): + """Notification of a new follower.""" + + active = False + + category = notification_categories.account + title = __("When I have a new follower") + description = __("See who is interested in your work") + + exclude_actor = True # The actor can't possibly receive this notification anyway + dispatch_roles = ['account_admin'] + + +# MARK: Project participant notifications ---------------------------------------------- class RegistrationConfirmationNotification( @@ -89,10 +110,10 @@ class RegistrationConfirmationNotification( """Notification confirming registration to a project.""" category = notification_categories.participant - title = __("When I register for a project") + title = __("When I register for a session") description = __("This will prompt a calendar entry in Gmail and other apps") - roles = ['owner'] + dispatch_roles = ['owner'] exclude_actor = False # This is a notification to the actor for_private_recipient = True @@ -105,26 +126,41 @@ class RegistrationCancellationNotification( ): """Notification confirming cancelling registration to a project.""" - roles = ['owner'] + dispatch_roles = ['owner'] exclude_actor = False # This is a notification to the actor for_private_recipient = True allow_web = False -class NewUpdateNotification( - DocumentHasProject, Notification[Update, None], type='update_new' +class ProjectUpdateNotification( + DocumentHasAccount, Notification[Project, Update], type='project_update' ): - """Notifications of new updates.""" + """Notification of a new update in a project.""" category = notification_categories.participant - title = __("When a project posts an update") + title = __("When there is an update") description = __( "Typically contains critical information such as video conference links" ) - roles = ['project_crew', 'project_participant', 'account_participant'] exclude_actor = False # Send to everyone including the actor + @property + def dispatch_roles(self) -> list[str]: + """Target roles based on Update visibility state.""" + # TODO: Use match/case matching here. If states use a Python Enum, Mypy will + # do an exhaustiveness check, so the closing RuntimeError is not needed. + # https://github.com/python/mypy/issues/6366 + visibility = self.fragment.visibility_state + if visibility.PUBLIC: + return ['project_crew', 'project_participant', 'account_follower'] + if visibility.PARTICIPANTS: + return ['project_crew', 'project_participant'] + if visibility.MEMBERS: + return ['project_crew', 'project_participant', 'account_member'] + + raise RuntimeError("Unknown update visibility state") + class ProposalSubmittedNotification( DocumentHasProject, Notification[Proposal, None], type='proposal_submitted' @@ -135,7 +171,7 @@ class ProposalSubmittedNotification( title = __("When I submit a proposal") description = __("Confirmation for your records") - roles = ['creator'] + dispatch_roles = ['creator'] exclude_actor = False # This notification is for the actor # Email is typically fine. Messengers may be too noisy @@ -154,14 +190,29 @@ class ProjectStartingNotification( """Notification of a session about to start.""" category = notification_categories.participant - title = __("When a project I’ve registered for is about to start") - description = __("You will be notified 5-10 minutes before the starting time") + title = __("When a session is starting soon") + description = __( + "You will be notified shortly before an online session, or a day before an" + " in-person session" + ) - roles = ['project_crew', 'project_participant'] + dispatch_roles = ['project_crew', 'project_participant'] # This is a notification triggered without an actor -# --- Comment notifications ------------------------------------------------------------ +class ProjectTomorrowNotification( + DocumentHasAccount, + Notification[Project, Session | None], + type='project_tomorrow', + shadows=ProjectStartingNotification, +): + """Notification of an in-person session the next day.""" + + dispatch_roles = ['project_crew', 'project_participant'] + # This is a notification triggered without an actor + + +# MARK: Comment notifications ---------------------------------------------------------- class NewCommentNotification(Notification[Commentset, Comment], type='comment_new'): @@ -171,7 +222,7 @@ class NewCommentNotification(Notification[Commentset, Comment], type='comment_ne title = __("When there is a new comment on something I’m involved in") exclude_actor = True - roles = ['replied_to_commenter', 'document_subscriber'] + dispatch_roles = ['replied_to_commenter', 'document_subscriber'] class CommentReplyNotification(Notification[Comment, Comment], type='comment_reply'): @@ -183,36 +234,36 @@ class CommentReplyNotification(Notification[Comment, Comment], type='comment_rep # document_model = Parent comment (being replied to) # fragment_model = Child comment (the reply that triggered notification) - roles = ['replied_to_commenter'] + dispatch_roles = ['replied_to_commenter'] -# --- Project crew notifications ------------------------------------------------------- +# MARK: Project crew notifications ----------------------------------------------------- -class ProjectCrewMembershipNotification( +class ProjectCrewNotification( DocumentHasAccount, Notification[Project, ProjectMembership], - type='project_crew_membership_granted', + type='project_crew', ): """Notification of being granted crew membership (including role changes).""" category = notification_categories.project_crew - title = __("When a project crew member is added or removed") + title = __("When crew members change") description = __("Crew members have access to the project’s settings and data") - roles = ['member', 'project_crew'] + dispatch_roles = ['member', 'project_crew'] exclude_actor = True # Alerts other users of actor's actions; too noisy for actor -class ProjectCrewMembershipRevokedNotification( +class ProjectCrewRevokedNotification( DocumentHasAccount, Notification[Project, ProjectMembership], - type='project_crew_membership_revoked', - shadows=ProjectCrewMembershipNotification, + type='project_crew_revoked', + shadows=ProjectCrewNotification, ): """Notification of being removed from crew membership (including role changes).""" - roles = ['member', 'project_crew'] + dispatch_roles = ['member', 'project_crew'] exclude_actor = True # Alerts other users of actor's actions; too noisy for actor @@ -224,7 +275,7 @@ class ProposalReceivedNotification( category = notification_categories.project_crew title = __("When my project receives a new proposal") - roles = ['project_editor'] + dispatch_roles = ['project_editor'] exclude_actor = True # Don't notify editor of proposal they submitted @@ -238,17 +289,17 @@ class RegistrationReceivedNotification( category = notification_categories.project_crew title = __("When someone registers for my project") - roles = ['project_promoter'] + dispatch_roles = ['project_promoter'] exclude_actor = True -# --- Organization admin notifications ------------------------------------------------- +# MARK: Account admin notifications ---------------------------------------------------- -class OrganizationAdminMembershipNotification( +class AccountAdminNotification( DocumentIsAccount, Notification[Account, AccountMembership], - type='organization_membership_granted', + type='account_admin', ): """Notification of being granted admin membership (including role changes).""" @@ -256,23 +307,25 @@ class OrganizationAdminMembershipNotification( title = __("When account admins change") description = __("Account admins control all projects under the account") - roles = ['member', 'account_admin'] + # Notify the affected individual and all account admins + dispatch_roles = ['member', 'account_admin'] exclude_actor = True # Alerts other users of actor's actions; too noisy for actor -class OrganizationAdminMembershipRevokedNotification( +class AccountAdminRevokedNotification( DocumentIsAccount, Notification[Account, AccountMembership], - type='organization_membership_revoked', - shadows=OrganizationAdminMembershipNotification, + type='account_admin_revoked', + shadows=AccountAdminNotification, ): - """Notification of being granted admin membership (including role changes).""" + """Notification of admin membership being revoked.""" - roles = ['member', 'account_admin'] + # Notify the affected individual and all account admins + dispatch_roles = ['member', 'account_admin'] exclude_actor = True # Alerts other users of actor's actions; too noisy for actor -# --- Site administrator notifications ------------------------------------------------- +# MARK: Site administrator notifications ----------------------------------------------- class CommentReportReceivedNotification( @@ -283,4 +336,4 @@ class CommentReportReceivedNotification( category = notification_categories.site_admin title = __("When a comment is reported as spam") - roles = ['comment_moderator'] + dispatch_roles = ['comment_moderator'] diff --git a/funnel/models/phone_number.py b/funnel/models/phone_number.py index 8f2ee87f8..f59cc08c4 100644 --- a/funnel/models/phone_number.py +++ b/funnel/models/phone_number.py @@ -4,8 +4,9 @@ import hashlib import warnings +from contextlib import suppress from datetime import datetime -from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, overload import base58 import phonenumbers @@ -46,7 +47,7 @@ 'PhoneNumberMixin', ] -# --- Enums and constants -------------------------------------------------------------- +# MARK: Enums and constants ------------------------------------------------------------ # Unprefixed phone numbers are assumed to be a local number in India (+91). A fallback @@ -58,7 +59,7 @@ PHONE_LOOKUP_REGIONS = ['IN'] -# --- Exceptions ----------------------------------------------------------------------- +# MARK: Exceptions --------------------------------------------------------------------- class PhoneNumberError(ValueError): @@ -77,7 +78,7 @@ class PhoneNumberInUseError(PhoneNumberError): """Phone number is in use by another owner.""" -# --- Utilities ------------------------------------------------------------------------ +# MARK: Utilities ---------------------------------------------------------------------- # Three phone number utilities are presented here. All three return a formatted phone @@ -114,24 +115,18 @@ def parse_phone_number( @overload -def parse_phone_number( - candidate: str, sms: bool | Literal[True] -) -> str | Literal[False] | None: ... +def parse_phone_number(candidate: str, sms: bool) -> str | Literal[False] | None: ... @overload def parse_phone_number( - candidate: str, - sms: bool | Literal[True], - parsed: Literal[True], + candidate: str, sms: bool, parsed: Literal[True] ) -> phonenumbers.PhoneNumber | Literal[False] | None: ... @overload def parse_phone_number( - candidate: str, - sms: bool | Literal[True], - parsed: bool | Literal[False], + candidate: str, sms: bool, parsed: bool ) -> phonenumbers.PhoneNumber | Literal[False] | None: ... @@ -159,7 +154,7 @@ def parse_phone_number( # with the _last_ valid candidate (as it's coupled with a # :class:`~funnel.models.account.AccountPhone` lookup) sms_invalid = False - try: + with suppress(phonenumbers.NumberParseException): for region in PHONE_LOOKUP_REGIONS: parsed_number = phonenumbers.parse(candidate, region) if phonenumbers.is_valid_number(parsed_number): @@ -174,8 +169,6 @@ def parse_phone_number( return phonenumbers.format_number( parsed_number, phonenumbers.PhoneNumberFormat.E164 ) - except phonenumbers.NumberParseException: - pass # We found a number that is valid, but the caller wanted it to be valid for SMS and # it isn't, so return a special flag if sms_invalid: @@ -226,7 +219,7 @@ def phone_blake2b160_hash( return hashlib.blake2b(number.encode('utf-8'), digest_size=20).digest() -# --- Models --------------------------------------------------------------------------- +# MARK: Models ------------------------------------------------------------------------- class PhoneNumber(BaseMixin[int, 'Account'], Model): @@ -367,10 +360,7 @@ def __init__(self, phone: str, *, _pre_validated_formatted: bool = False) -> Non super().__init__() if not isinstance(phone, str): raise ValueError("A string phone number is required") - if not _pre_validated_formatted: - number = validate_phone_number(phone) - else: - number = phone + number = validate_phone_number(phone) if not _pre_validated_formatted else phone # Set the hash first so the phone column validator passes. self.blake2b160 = phone_blake2b160_hash(number, _pre_validated_formatted=True) self.number = number @@ -564,7 +554,7 @@ def get( phone: str | phonenumbers.PhoneNumber, *, is_blocked: bool | None = None, - ) -> PhoneNumber | None: ... + ) -> Self | None: ... @overload @classmethod @@ -573,7 +563,7 @@ def get( *, blake2b160: bytes, is_blocked: bool | None = None, - ) -> PhoneNumber | None: ... + ) -> Self | None: ... @overload @classmethod @@ -582,7 +572,7 @@ def get( *, phone_hash: str, is_blocked: bool | None = None, - ) -> PhoneNumber | None: ... + ) -> Self | None: ... @classmethod def get( @@ -592,7 +582,7 @@ def get( blake2b160: bytes | None = None, phone_hash: str | None = None, is_blocked: bool | None = None, - ) -> PhoneNumber | None: + ) -> Self | None: """ Get an :class:`PhoneNumber` instance by normalized phone number or its hash. @@ -614,7 +604,7 @@ def get( return query.one_or_none() @classmethod - def add(cls, phone: str | phonenumbers.PhoneNumber) -> PhoneNumber: + def add(cls, phone: str | phonenumbers.PhoneNumber) -> Self: """ Create a new :class:`PhoneNumber` after normalization and validation. @@ -634,7 +624,7 @@ def add(cls, phone: str | phonenumbers.PhoneNumber) -> PhoneNumber: if not existing.number: existing.number = number return existing - new_phone = PhoneNumber(number, _pre_validated_formatted=True) + new_phone = cls(number, _pre_validated_formatted=True) db.session.add(new_phone) return new_phone @@ -643,7 +633,7 @@ def add_for( cls, owner: Account | None, phone: str | phonenumbers.PhoneNumber, - ) -> PhoneNumber: + ) -> Self: """ Create a new :class:`PhoneNumber` after validation. @@ -662,7 +652,7 @@ def add_for( # No exclusive lock found? Let it be used then existing.number = number # In case it was nulled earlier return existing - new_phone = PhoneNumber(number, _pre_validated_formatted=True) + new_phone = cls(number, _pre_validated_formatted=True) db.session.add(new_phone) return new_phone @@ -711,13 +701,17 @@ def validate_for( @classmethod def get_numbers(cls, prefix: str, remove: bool = True) -> set[str]: - """Get all numbers with the given prefix as a Python set.""" + """ + Get all numbers with the given prefix as a Python set. + + :param remove: Remove prefix from the results + """ query = ( cls.query.filter(cls.number.startswith(prefix)) .options(sa_orm.load_only(cls.number)) .yield_per(1000) ) - # This query only has results where `.number` is not None, so we type checkers + # This query only has results where `.number` is not None, so type checkers # have to be told to ignore the possibility of a null: if remove: skip = len(prefix) @@ -797,11 +791,10 @@ def phone(self, __value: str | None) -> None: ) else: self.phone_number = None + elif __value is not None: + self.phone_number = PhoneNumber.add(__value) else: - if __value is not None: - self.phone_number = PhoneNumber.add(__value) - else: - self.phone_number = None + self.phone_number = None @property def phone_number_reference_is_active(self) -> bool: @@ -816,11 +809,8 @@ def phone_number_reference_is_active(self) -> bool: @property def transport_hash(self) -> str | None: """Phone hash using the compatibility name for notifications framework.""" - return ( - self.phone_number.phone_hash - if self.phone_number # pylint: disable=using-constant-test - else None - ) + # pylint: disable=using-constant-test + return self.phone_number.phone_hash if self.phone_number else None @declarative_mixin @@ -854,11 +844,9 @@ def transport_hash(self) -> str: ... def _clear_cached_properties(target: PhoneNumber) -> None: """Clear cached properties in :class:`PhoneNumber`.""" for attr in ('parsed', 'formatted'): - try: - delattr(target, attr) - except KeyError: + with suppress(KeyError): # cached_property raises KeyError when there's no existing cached value - pass + delattr(target, attr) @event.listens_for(PhoneNumber.number, 'set', retval=True) diff --git a/funnel/models/project.py b/funnel/models/project.py index 7e4b30370..c8dad0ec6 100644 --- a/funnel/models/project.py +++ b/funnel/models/project.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections import OrderedDict, defaultdict -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from datetime import datetime, timedelta from enum import ReprEnum from typing import TYPE_CHECKING, Any, Literal, Self, cast, overload @@ -20,12 +20,12 @@ from baseframe import __, localize_timezone from coaster.sqlalchemy import ( DynamicAssociationProxy, - LazyRoleSet, ManagedState, StateManager, + role_check, with_roles, ) -from coaster.utils import LabeledEnum, buid, utcnow +from coaster.utils import LabeledEnum, NameTitle, buid, utcnow from .. import app from . import types @@ -60,22 +60,22 @@ __all__ = ['ProjectRsvpStateEnum', 'Project', 'ProjectLocation', 'ProjectRedirect'] -# --- Constants --------------------------------------------------------------- +# MARK: Constants ------------------------------------------------------------- class PROJECT_STATE(LabeledEnum): # noqa: N801 - DRAFT = (1, 'draft', __("Draft")) - PUBLISHED = (2, 'published', __("Published")) - WITHDRAWN = (3, 'withdrawn', __("Withdrawn")) - DELETED = (4, 'deleted', __("Deleted")) + DRAFT = (1, NameTitle('draft', __("Draft"))) + PUBLISHED = (2, NameTitle('published', __("Published"))) + WITHDRAWN = (3, NameTitle('withdrawn', __("Withdrawn"))) + DELETED = (4, NameTitle('deleted', __("Deleted"))) DELETABLE = {DRAFT, PUBLISHED, WITHDRAWN} PUBLISHABLE = {DRAFT, WITHDRAWN} class CFP_STATE(LabeledEnum): # noqa: N801 - NONE = (1, 'none', __("None")) - PUBLIC = (2, 'public', __("Public")) - CLOSED = (3, 'closed', __("Closed")) + NONE = (1, NameTitle('none', __("None"))) + PUBLIC = (2, NameTitle('public', __("Public"))) + CLOSED = (3, NameTitle('closed', __("Closed"))) ANY = {NONE, PUBLIC, CLOSED} @@ -85,7 +85,7 @@ class ProjectRsvpStateEnum(IntTitle, ReprEnum): MEMBERS = 3, __("Only members can register") -# --- Models ------------------------------------------------------------------ +# MARK: Models ---------------------------------------------------------------- class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): @@ -102,11 +102,12 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): account: Mapped[Account] = with_roles( relationship(foreign_keys=[account_id], back_populates='projects'), read={'all'}, - # If account grants an 'admin' role, make it 'account_admin' here + # Remap account roles for use in project grants_via={ None: { + 'owner': 'account_owner', 'admin': 'account_admin', - 'follower': 'account_participant', + 'follower': 'account_follower', 'member': 'account_member', } }, @@ -136,7 +137,7 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) - parsed_location: Mapped[types.jsonb_dict] + parsed_location: Mapped[types.JsonbDict] website: Mapped[furl | None] = with_roles( sa_orm.mapped_column(UrlType, nullable=True), @@ -156,7 +157,10 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): _state: Mapped[int] = sa_orm.mapped_column( 'state', - StateManager.check_constraint('state', PROJECT_STATE, sa.Integer), + sa.SmallInteger, + StateManager.check_constraint( + 'state', PROJECT_STATE, sa.SmallInteger, name='project_state_check' + ), default=PROJECT_STATE.DRAFT, nullable=False, index=True, @@ -167,7 +171,10 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): ) _cfp_state: Mapped[int] = sa_orm.mapped_column( 'cfp_state', - StateManager.check_constraint('cfp_state', CFP_STATE, sa.Integer), + sa.SmallInteger, + StateManager.check_constraint( + 'cfp_state', CFP_STATE, sa.SmallInteger, name='project_cfp_state_check' + ), default=CFP_STATE.NONE, nullable=False, index=True, @@ -181,7 +188,10 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): sa_orm.mapped_column( sa.SmallInteger, StateManager.check_constraint( - 'rsvp_state', ProjectRsvpStateEnum, sa.SmallInteger + 'rsvp_state', + ProjectRsvpStateEnum, + sa.SmallInteger, + name='project_rsvp_state_check', ), default=ProjectRsvpStateEnum.NONE, nullable=False, @@ -246,7 +256,7 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): read={'all'}, datasets={'primary', 'without_parent'}, ) - boxoffice_data: Mapped[types.jsonb_dict] = with_roles( + boxoffice_data: Mapped[types.JsonbDict] = with_roles( sa_orm.mapped_column(), # This is an attribute, but we deliberately use `call` instead of `read` to # block this from dictionary enumeration. FIXME: Break up this dictionary into @@ -281,7 +291,9 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): parent_project: Mapped[Project | None] = relationship( remote_side='Project.id', back_populates='subprojects' ) - subprojects: Mapped[list[Project]] = relationship(back_populates='parent_project') + subprojects: Mapped[list[Project]] = relationship( + back_populates='parent_project', order_by=lambda: Project.order_by_date().desc() + ) #: Featured project flag. This can only be set by website editors, not #: project editors or account admins. @@ -294,7 +306,7 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): livestream_urls: Mapped[list[str] | None] = with_roles( sa_orm.mapped_column( - sa.ARRAY(sa.UnicodeText, dimensions=1), + sa.ARRAY(sa.Unicode, dimensions=1), nullable=True, # For legacy data server_default=sa.text("'{}'::text[]"), default=None, @@ -339,7 +351,7 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): deferred=True, ) - # --- Backrefs and relationships + # MARK: Backrefs and relationships redirects: Mapped[list[ProjectRedirect]] = relationship(back_populates='project') locations: Mapped[list[ProjectLocation]] = relationship(back_populates='project') @@ -372,7 +384,16 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): ), viewonly=True, ), - grants_via={'member': {'editor', 'promoter', 'usher', 'participant', 'crew'}}, + # Get subset of roles via offered_roles property + grants_via={ + 'member': { + 'crew': {'crew', 'project_crew'}, + 'editor': {'editor', 'project_editor'}, + 'participant': {'participant', 'project_participant'}, + 'promoter': {'promoter', 'project_promoter'}, + 'usher': {'usher', 'project_usher'}, + } + }, ) active_editor_memberships: DynamicMapped[ProjectMembership] = relationship( @@ -405,17 +426,30 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): viewonly=True, ) - crew = DynamicAssociationProxy[Account]('active_crew_memberships', 'member') - editors = DynamicAssociationProxy[Account]('active_editor_memberships', 'member') - promoters = DynamicAssociationProxy[Account]( - 'active_promoter_memberships', 'member' + crew: DynamicAssociationProxy[Account, ProjectMembership] = DynamicAssociationProxy( + 'active_crew_memberships', 'member' + ) + editors: DynamicAssociationProxy[Account, ProjectMembership] = ( + DynamicAssociationProxy('active_editor_memberships', 'member') + ) + promoters: DynamicAssociationProxy[Account, ProjectMembership] = ( + DynamicAssociationProxy('active_promoter_memberships', 'member') + ) + ushers: DynamicAssociationProxy[Account, ProjectMembership] = ( + DynamicAssociationProxy('active_usher_memberships', 'member') ) - ushers = DynamicAssociationProxy[Account]('active_usher_memberships', 'member') # proposal.py proposals: DynamicMapped[Proposal] = relationship( lazy='dynamic', order_by=lambda: Proposal.seq, back_populates='project' ) + proposal_templates: Mapped[list[Proposal]] = relationship( + primaryjoin=lambda: sa.and_( + Proposal.project_id == Project.id, Proposal.state.TEMPLATE + ), + viewonly=True, + order_by=lambda: Proposal.seq, + ) # rsvp.py rsvps: DynamicMapped[Rsvp] = relationship(lazy='dynamic', back_populates='project') @@ -461,7 +495,9 @@ class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): def has_sponsors(self) -> bool: return db.session.query(self.sponsor_memberships.exists()).scalar() - sponsors = DynamicAssociationProxy[Account]('sponsor_memberships', 'member') + sponsors: DynamicAssociationProxy[Account, ProjectSponsorMembership] = ( + DynamicAssociationProxy('sponsor_memberships', 'member') + ) # sync_ticket.py ticket_clients: Mapped[list[TicketClient]] = relationship(back_populates='project') @@ -502,6 +538,8 @@ def has_sponsors(self) -> bool: def rooms(self) -> list[VenueRoom]: return [room for venue in self.venues for room in venue.rooms] + # MARK: Model config + __table_args__ = ( sa.UniqueConstraint('account_id', 'name'), sa.Index('ix_project_search_vector', 'search_vector', postgresql_using='gin'), @@ -539,6 +577,7 @@ def rooms(self) -> list[VenueRoom]: 'created_at', # From TimestampMixin, used for vCal render timestamp 'updated_at', # From TimestampMixin, used for vCal render timestamp 'subprojects', + 'parent_project', }, 'call': { 'features', # From RegistryMixin @@ -569,6 +608,8 @@ def rooms(self) -> list[VenueRoom]: }, } + # MARK: Conditional states + state.add_conditional_state( 'PAST', state.PUBLISHED, @@ -609,12 +650,14 @@ def rooms(self) -> list[VenueRoom]: 'HAS_PROPOSALS', cfp_state.ANY, lambda project: db.session.query(project.proposals.exists()).scalar(), + lambda project: project.proposals.exists(), label=('has_proposals', __("Has submissions")), ) cfp_state.add_conditional_state( 'HAS_SESSIONS', cfp_state.ANY, lambda project: db.session.query(project.sessions.exists()).scalar(), + lambda project: project.sessions.exists(), label=('has_sessions', __("Has sessions")), ) cfp_state.add_conditional_state( @@ -658,6 +701,8 @@ def rooms(self) -> list[VenueRoom]: cfp_state.EXPIRED, ) + # MARK: Magic methods + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.commentset = Commentset(settype=SET_TYPE.PROJECT) @@ -683,6 +728,33 @@ def __format__(self, format_spec: str) -> str: return self.joined_title return format(self.joined_title, format_spec) + # MARK: Methods and properties + + @role_check('member_participant') + def has_member_participant_role( + self, actor: Account | None, _anchors: Sequence[Any] = () + ) -> bool: + """Confirm if the actor is both a participant and an account member.""" + if actor is None: + return False + roles = self.roles_for(actor) + if 'participant' in roles and 'account_member' in roles: + return True + return False + + @has_member_participant_role.iterable + def _(self) -> Iterable[Account]: + """All participants who are also account members.""" + # TODO: This iterable causes a loop of SQL queries. It will be far more + # efficient to SQL JOIN the data sources once they are single-table sources. + # Rsvp needs to merge into ProjectMembership, and AccountMembership needs to + # replace the hacky "membership project" data source. + return ( + account + for account in self.actors_with({'participant'}) + if 'account_member' in self.roles_for(account) + ) + @with_roles(call={'editor'}) @cfp_state.transition( cfp_state.OPENABLE, @@ -742,10 +814,13 @@ def withdraw(self) -> None: @property def title_inline(self) -> str: """Suffix a colon if the title does not end in ASCII sentence punctuation.""" - if self.title and self.tagline: - # pylint: disable=unsubscriptable-object - if self.title[-1] not in ('?', '!', ':', ';', '.', ','): - return self.title + ':' + # pylint: disable=unsubscriptable-object + if ( + self.title + and self.tagline + and self.title[-1] not in ('?', '!', ':', ';', '.', ',') + ): + return self.title + ':' return self.title with_roles(title_inline, read={'all'}, datasets={'primary', 'without_parent'}) @@ -764,7 +839,7 @@ def title_suffix(self) -> str: with_roles(title_suffix, read={'all'}) @property - def title_parts(self) -> list[str]: + def title_parts(self) -> tuple[str] | tuple[str, str]: """ Return the hierarchy of titles of this project. @@ -777,9 +852,9 @@ 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.account.title, self.title] + return (self.account.title, self.title) # Project title extends account title, so account title is not needed - return [self.title] + return (self.title,) with_roles(title_parts, read={'all'}) @@ -925,21 +1000,19 @@ def rsvp_for(self, account: Account | None, create: bool = False) -> Rsvp | None return Rsvp.get_for(self, account, create) def rsvps_with(self, state: RsvpStateEnum) -> Query[Rsvp]: + # pylint: disable=protected-access return self.rsvps.join(Account).filter( - Account.state.ACTIVE, - Rsvp._state == state, # pylint: disable=protected-access + Account.state.ACTIVE, Rsvp._state == state ) def rsvp_counts(self) -> dict[str, int]: + # pylint: disable=protected-access return { row[0]: row[1] - for row in db.session.query( - Rsvp._state, # pylint: disable=protected-access - sa.func.count(Rsvp._state), # pylint: disable=protected-access - ) + for row in db.session.query(Rsvp._state, sa.func.count(Rsvp._state)) .join(Account) .filter(Account.state.ACTIVE, Rsvp.project == self) - .group_by(Rsvp._state) # pylint: disable=protected-access + .group_by(Rsvp._state) .all() } @@ -956,13 +1029,12 @@ def update_schedule_timestamps(self) -> None: self.start_at = self.schedule_start_at self.end_at = self.schedule_end_at - def roles_for( - self, actor: Account | None = None, anchors: Sequence = () - ) -> LazyRoleSet: - roles = super().roles_for(actor, anchors) - # https://github.com/hasgeek/funnel/pull/220#discussion_r168718052 - roles.add('reader') - return roles + @role_check('reader') + def has_reader_role( + self, _actor: Account | None, _anchors: Sequence[Any] = () + ) -> bool: + """Unconditionally grant reader role (for now).""" + return True def is_safe_to_delete(self) -> bool: """Return True if project has no proposals.""" @@ -1083,6 +1155,7 @@ def session_count(self) -> int: sessions_with_video: DynamicMapped[Session] = with_roles( relationship( lazy='dynamic', + order_by=lambda: Session.start_at.desc(), primaryjoin=lambda: sa.and_( Project.id == Session.project_id, Session.video_id.is_not(None), @@ -1347,14 +1420,14 @@ def calendar_weeks(self, leading_weeks: bool = True) -> dict[str, Any]: session_dates_dict[date]['day_start_at'] .astimezone(self.timezone) .strftime('%I:%M %p') - if date in session_dates_dict.keys() + if date in session_dates_dict else None ), 'day_end_at': ( session_dates_dict[date]['day_end_at'] .astimezone(self.timezone) .strftime('%I:%M %p %Z') - if date in session_dates_dict.keys() + if date in session_dates_dict else None ), } @@ -1408,11 +1481,10 @@ def order_by_date(cls) -> sa.Case: param bool desc: Use descending order (default True) """ - clause = sa.case( + return sa.case( (cls.start_at.is_not(None), cls.start_at), else_=cls.published_at, ) - return clause @classmethod def all_unsorted(cls) -> Query[Self]: @@ -1424,7 +1496,7 @@ def all_unsorted(cls) -> Query[Self]: ) @classmethod - def all(cls) -> Query[Self]: # noqa: A003 + def all(cls) -> Query[Self]: """Return all published projects, ordered by date.""" return cls.all_unsorted().order_by(cls.order_by_date()) @@ -1575,6 +1647,7 @@ def __repr__(self) -> str: from .saved import SavedProject from .sync_ticket import TicketClient, TicketEvent, TicketParticipant, TicketType +# MARK: Additional column properties # Whether the project has any featured proposals. Returns `None` instead of # a boolean if the project does not have any proposal. diff --git a/funnel/models/project_membership.py b/funnel/models/project_membership.py index 28097f0af..a1699a1ee 100644 --- a/funnel/models/project_membership.py +++ b/funnel/models/project_membership.py @@ -56,6 +56,7 @@ class ProjectMembership(ImmutableMembershipMixin, Model): 'is_promoter', 'is_usher', 'label', + 'bio', } }, 'project_crew': { @@ -81,6 +82,7 @@ class ProjectMembership(ImmutableMembershipMixin, Model): 'label', 'member', 'project', + 'bio', }, 'without_parent': { 'urls', @@ -91,6 +93,7 @@ class ProjectMembership(ImmutableMembershipMixin, Model): 'is_usher', 'label', 'member', + 'bio', }, 'related': { 'urls', @@ -100,6 +103,7 @@ class ProjectMembership(ImmutableMembershipMixin, Model): 'is_promoter', 'is_usher', 'label', + 'bio', }, } @@ -158,6 +162,13 @@ def __table_args__(cls) -> tuple: # type: ignore[override] args.append(kwargs) return tuple(args) + @property + def bio(self) -> str | None: + """Member's biography line, from their account.""" + # TODO: Use the account membership label if available (requires discovery of + # account membership instance) + return self.member.tagline + @cached_property def offered_roles(self) -> set[str]: """Roles offered by this membership record.""" diff --git a/funnel/models/proposal.py b/funnel/models/proposal.py index ad75bd4e1..bbfdd1242 100644 --- a/funnel/models/proposal.py +++ b/funnel/models/proposal.py @@ -12,11 +12,11 @@ from baseframe.filters import preview from coaster.sqlalchemy import ( DynamicAssociationProxy, - LazyRoleSet, StateManager, + role_check, with_roles, ) -from coaster.utils import LabeledEnum +from coaster.utils import LabeledEnum, NameTitle from .account import Account from .base import ( @@ -49,31 +49,31 @@ _marker = object() -# --- Constants ------------------------------------------------------------------ +# MARK: Constants ---------------------------------------------------------------- class PROPOSAL_STATE(LabeledEnum): # noqa: N801 # Draft-state for future use, so people can save their proposals and submit only # when ready. If you add any new state, you need to add a migration to modify the # check constraint - DRAFT = (1, 'draft', __("Draft")) - SUBMITTED = (2, 'submitted', __("Submitted")) - CONFIRMED = (3, 'confirmed', __("Confirmed")) - WAITLISTED = (4, 'waitlisted', __("Waitlisted")) - REJECTED = (6, 'rejected', __("Rejected")) - CANCELLED = (7, 'cancelled', __("Cancelled")) - AWAITING_DETAILS = (8, 'awaiting_details', __("Awaiting details")) - UNDER_EVALUATION = (9, 'under_evaluation', __("Under evaluation")) - DELETED = (12, 'deleted', __("Deleted")) + DRAFT = (1, NameTitle('draft', __("Draft"))) + SUBMITTED = (2, NameTitle('submitted', __("Submitted"))) + CONFIRMED = (3, NameTitle('confirmed', __("Confirmed"))) + WAITLISTED = (4, NameTitle('waitlisted', __("Waitlisted"))) + REJECTED = (6, NameTitle('rejected', __("Rejected"))) + CANCELLED = (7, NameTitle('cancelled', __("Cancelled"))) + AWAITING_DETAILS = (8, NameTitle('awaiting_details', __("Awaiting details"))) + UNDER_EVALUATION = (9, NameTitle('under_evaluation', __("Under evaluation"))) + DELETED = (12, NameTitle('deleted', __("Deleted"))) + TEMPLATE = (13, NameTitle('template', __("Template"))) # These 3 are not in the editorial workflow anymore - Feb 23 2018 - SHORTLISTED = (5, 'shortlisted', __("Shortlisted")) + SHORTLISTED = (5, NameTitle('shortlisted', __("Shortlisted"))) SHORTLISTED_FOR_REHEARSAL = ( 10, - 'shortlisted_for_rehearsal', - __("Shortlisted for rehearsal"), + NameTitle('shortlisted_for_rehearsal', __("Shortlisted for rehearsal")), ) - REHEARSAL = (11, 'rehearsal', __("Rehearsal ongoing")) + REHEARSAL = (11, NameTitle('rehearsal', __("Rehearsal ongoing"))) # Groups PUBLIC = { # States visible to the public @@ -109,6 +109,7 @@ class PROPOSAL_STATE(LabeledEnum): # noqa: N801 REJECTED, AWAITING_DETAILS, UNDER_EVALUATION, + TEMPLATE, } CANCELLABLE = { DRAFT, @@ -123,7 +124,7 @@ class PROPOSAL_STATE(LabeledEnum): # noqa: N801 # SHORLISTABLE = {SUBMITTED, AWAITING_DETAILS, UNDER_EVALUATION} -# --- Models ------------------------------------------------------------------ +# MARK: Models ---------------------------------------------------------------- class Proposal(UuidMixin, BaseScopedIdNameMixin, VideoMixin, ReorderMixin, Model): @@ -162,7 +163,10 @@ class Proposal(UuidMixin, BaseScopedIdNameMixin, VideoMixin, ReorderMixin, Model _state: Mapped[int] = sa_orm.mapped_column( 'state', - StateManager.check_constraint('state', PROPOSAL_STATE, sa.Integer), + sa.SmallInteger, + StateManager.check_constraint( + 'state', PROPOSAL_STATE, sa.SmallInteger, name='proposal_state_check' + ), default=PROPOSAL_STATE.SUBMITTED, nullable=False, ) @@ -355,6 +359,9 @@ def __format__(self, format_spec: str) -> str: 'SCHEDULED', state.CONFIRMED, lambda proposal: proposal.session is not None and proposal.session.scheduled, + lambda proposal: sa.and_( + proposal.session.isnot(None), proposal.session.scheduled + ), label=('scheduled', __("Confirmed & scheduled")), ) @@ -492,6 +499,28 @@ def under_evaluation(self) -> None: def delete(self) -> None: pass + @with_roles(call={'project_editor'}) # skipcq: PTC-W0049 + @state.transition( + state.SUBMITTED, + state.TEMPLATE, + title=__("Convert to template"), + message=__("This proposal has been converted into a template"), + type='success', + ) + def make_template(self) -> None: + pass + + @with_roles(call={'project_editor'}) # skipcq: PTC-W0049 + @state.transition( + state.TEMPLATE, + state.SUBMITTED, + title=__("Convert to submission"), + message=__("This proposal has been converted into a submission"), + type='success', + ) + def undo_template(self) -> None: + pass + @property def first_user(self) -> Account: """Return the first credited member on the proposal, or creator if none.""" @@ -537,23 +566,23 @@ def getprev(self) -> Proposal | None: def has_sponsors(self) -> bool: return db.session.query(self.sponsor_memberships.exists()).scalar() - sponsors = DynamicAssociationProxy[Account]('sponsor_memberships', 'member') - - def roles_for( - self, actor: Account | None = None, anchors: Sequence = () - ) -> LazyRoleSet: - roles = super().roles_for(actor, anchors) - if self.state.DRAFT: - if 'reader' in roles: - # https://github.com/hasgeek/funnel/pull/220#discussion_r168724439 - roles.remove('reader') - else: - roles.add('reader') + sponsors: DynamicAssociationProxy[Account, ProposalSponsorMembership] = ( + DynamicAssociationProxy('sponsor_memberships', 'member') + ) - if roles.has_any(('project_participant', 'submitter')): - roles.add('commenter') + @role_check('reader') + def has_reader_role( + self, _actor: Account | None, _anchors: Sequence[Any] = () + ) -> bool: + """Grant reader role if the proposal is not a draft.""" + return not self.state.DRAFT - return roles + @role_check('commenter') + def has_commenter_role( + self, actor: Account | None, _anchors: Sequence[Any] = () + ) -> bool: + """Grant 'commenter' role to any participant or submitter.""" + return self.roles_for(actor).has_any(('project_participant', 'submitter')) @classmethod def all_public(cls) -> Query[Self]: diff --git a/funnel/models/rsvp.py b/funnel/models/rsvp.py index 8662d8202..30e51e7d4 100644 --- a/funnel/models/rsvp.py +++ b/funnel/models/rsvp.py @@ -10,7 +10,7 @@ from baseframe import __ from coaster.sqlalchemy import StateManager, with_roles -from coaster.utils import DataclassFromType, LabeledEnum +from coaster.utils import DataclassFromType, LabeledEnum, NameTitle from . import types from .account import Account, AccountEmail, AccountEmailClaim, AccountPhone @@ -34,10 +34,10 @@ class RSVP_STATUS(LabeledEnum): # noqa: N801 # If you add any new state, you need to add a migration to modify the check # constraint - YES = ('Y', 'yes', __("Going")) - NO = ('N', 'no', __("Not going")) - MAYBE = ('M', 'maybe', __("Maybe")) - AWAITING = ('A', 'awaiting', __("Awaiting")) + YES = ('Y', NameTitle('yes', __("Going"))) + NO = ('N', NameTitle('no', __("Not going"))) + MAYBE = ('M', NameTitle('maybe', __("Maybe"))) + AWAITING = ('A', NameTitle('awaiting', __("Awaiting"))) @dataclass(frozen=True) @@ -76,7 +76,7 @@ class Rsvp(UuidMixin, NoIdMixin, Model): grants={'owner'}, datasets={'primary', 'without_parent'}, ) - form: Mapped[types.jsonb | None] = with_roles( + form: Mapped[types.Jsonb | None] = with_roles( sa_orm.mapped_column(), rw={'owner'}, read={'project_promoter'}, @@ -86,7 +86,9 @@ class Rsvp(UuidMixin, NoIdMixin, Model): _state: Mapped[str] = sa_orm.mapped_column( 'state', sa.CHAR(1), - StateManager.check_constraint('state', RsvpStateEnum, sa.CHAR(1)), + StateManager.check_constraint( + 'state', RsvpStateEnum, sa.CHAR(1), name='rsvp_state_check' + ), default=RsvpStateEnum.AWAITING, nullable=False, ) diff --git a/funnel/models/saved.py b/funnel/models/saved.py index 53a30fc91..441bf3273 100644 --- a/funnel/models/saved.py +++ b/funnel/models/saved.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Sequence from datetime import datetime -from coaster.sqlalchemy import LazyRoleSet, with_roles +from coaster.sqlalchemy import with_roles from .account import Account from .base import Mapped, Model, NoIdMixin, db, relationship, sa, sa_orm @@ -68,7 +67,9 @@ class SavedSession(NoIdMixin, Model): nullable=False, primary_key=True, ) - account: Mapped[Account] = relationship(back_populates='saved_sessions') + account: Mapped[Account] = with_roles( + relationship(back_populates='saved_sessions'), grants={'owner'} + ) #: Session that was saved session_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('session.id', ondelete='CASCADE'), @@ -90,14 +91,6 @@ class SavedSession(NoIdMixin, Model): sa.UnicodeText, nullable=True ) - def roles_for( - self, actor: Account | None = None, anchors: Sequence = () - ) -> LazyRoleSet: - roles = super().roles_for(actor, anchors) - if actor is not None and actor == self.account: - roles.add('owner') - return roles - @classmethod def migrate_account(cls, old_account: Account, new_account: Account) -> None: """Migrate one account's data to another when merging accounts.""" diff --git a/funnel/models/session.py b/funnel/models/session.py index 4b775d5a7..ffcf1597b 100644 --- a/funnel/models/session.py +++ b/funnel/models/session.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, Self +from furl import furl from werkzeug.utils import cached_property from baseframe import localize_timezone @@ -73,7 +74,7 @@ class Session(UuidMixin, BaseScopedIdNameMixin[int, Account], VideoMixin, Model) is_break: Mapped[bool] = sa_orm.mapped_column(default=False) featured: Mapped[bool] = sa_orm.mapped_column(default=False) is_restricted_video: Mapped[bool] = sa_orm.mapped_column(default=False) - banner_image_url: Mapped[str | None] = sa_orm.mapped_column( + banner_image_url: Mapped[furl | None] = sa_orm.mapped_column( ImgeeType, nullable=True ) @@ -217,7 +218,7 @@ def scheduled(self) -> bool: @classmethod def _scheduled_expression(cls) -> sa.ColumnElement[bool]: """Return SQL Expression.""" - return (cls.start_at.is_not(None)) & (cls.end_at.is_not(None)) + return cls.start_at.is_not(None) & cls.end_at.is_not(None) @cached_property def start_at_localized(self) -> datetime | None: diff --git a/funnel/models/shortlink.py b/funnel/models/shortlink.py index c0d03ce30..19bb5e64d 100644 --- a/funnel/models/shortlink.py +++ b/funnel/models/shortlink.py @@ -7,7 +7,7 @@ from base64 import urlsafe_b64decode, urlsafe_b64encode from collections.abc import Iterable from os import urandom -from typing import Any, Literal, overload +from typing import Literal, overload from furl import furl from sqlalchemy.exc import IntegrityError @@ -32,7 +32,7 @@ __all__ = ['Shortlink'] -# --- Constants ------------------------------------------------------------------------ +# MARK: Constants ---------------------------------------------------------------------- #: Size for for SMS and other length-sensitive uses. This can be raised from 3 to 4 #: bytes as usage grows @@ -55,7 +55,7 @@ _valid_name_re = re.compile('^[A-Za-z0-9_-]*$') -# --- Helpers -------------------------------------------------------------------------- +# MARK: Helpers ------------------------------------------------------------------------ def normalize_url(url: str | furl, default_scheme: str = 'https') -> furl: @@ -175,12 +175,10 @@ class ShortLinkToBigIntComparator(Comparator): # pylint: disable=abstract-metho If the provided name is invalid, :func:`name_to_bigint` will raise exceptions. """ - def __eq__(self, other: Any) -> sa.ColumnElement[bool]: # type: ignore[override] + def __eq__(self, other: object) -> sa.ColumnElement[bool]: # type: ignore[override] """Return an expression for column == other.""" - if isinstance(other, (str, bytes)): - return self.__clause_element__() == name_to_bigint( - other - ) # type: ignore[return-value] + if isinstance(other, str | bytes): + return self.__clause_element__() == name_to_bigint(other) # type: ignore[return-value] return sa.sql.expression.false() is_ = __eq__ # type: ignore[assignment] @@ -194,7 +192,7 @@ def in_( # type: ignore[override] ) -# --- Models --------------------------------------------------------------------------- +# MARK: Models ------------------------------------------------------------------------- class Shortlink(NoIdMixin, Model): @@ -207,7 +205,7 @@ class Shortlink(NoIdMixin, Model): is_new = False # id of this shortlink, saved as a bigint (8 bytes) - id: Mapped[int] = with_roles( # noqa: A003 + id: Mapped[int] = with_roles( # id cannot use the `immutable` wrapper because :meth:`new` changes the id when # handling collisions. This needs an "immutable after commit" handler sa_orm.mapped_column( @@ -248,7 +246,7 @@ def _name_comparator(cls) -> ShortLinkToBigIntComparator: """Compare name to id in a SQL expression.""" return ShortLinkToBigIntComparator(cls.id) - # --- Validators + # MARK: Validators @sa_orm.validates('id') def _validate_id_not_zero(self, _key: str, value: int) -> int: @@ -258,12 +256,11 @@ def _validate_id_not_zero(self, _key: str, value: int) -> int: @sa_orm.validates('url') def _validate_url(self, _key: str, value: str) -> str: - value = str(normalize_url(value)) - # If URL hashes are added to the model, the value must be set here using - # `url_blake2b160_hash(value)` - return value + # If URL hashes are added to the Shortlink model, the hash value must be set + # here using `url_blake2b160_hash(value)` + return str(normalize_url(value)) - # --- Methods + # MARK: Methods def __repr__(self) -> str: """Return string representation of self.""" diff --git a/funnel/models/sync_ticket.py b/funnel/models/sync_ticket.py index d07b1e313..716584c20 100644 --- a/funnel/models/sync_ticket.py +++ b/funnel/models/sync_ticket.py @@ -4,10 +4,11 @@ import base64 import os -from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING, Any, Self +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Protocol, Self +from uuid import UUID -from coaster.sqlalchemy import LazyRoleSet, with_roles +from coaster.sqlalchemy import with_roles from .account import Account, AccountEmail from .base import ( @@ -27,6 +28,7 @@ from .project_membership import project_child_role_map __all__ = [ + 'CheckinParticipantProtocol', 'SyncTicket', 'TicketClient', 'TicketEvent', @@ -69,6 +71,17 @@ def make_private_key() -> str: ) +class CheckinParticipantProtocol(Protocol): + uuid: UUID + fullname: str + company: str + email: str | None + badge_printed: bool + checked_in: bool + ticket_type_titles: str # Comma separated string + has_user: bool + + class GetTitleMixin(BaseScopedNameMixin): @classmethod def get( @@ -258,8 +271,8 @@ class TicketParticipant( participant_id: Mapped[int | None] = sa_orm.mapped_column( sa.ForeignKey('account.id'), default=None, nullable=True ) - participant: Mapped[Account | None] = relationship( - back_populates='ticket_participants' + participant: Mapped[Account | None] = with_roles( + relationship(back_populates='ticket_participants'), grants={'member'} ) project_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('project.id'), default=None, nullable=False @@ -270,8 +283,9 @@ class TicketParticipant( grants_via={None: project_child_role_map}, ) - scanned_contacts: Mapped[ContactExchange] = relationship( - passive_deletes=True, back_populates='ticket_participant' + scanned_contacts: Mapped[ContactExchange] = with_roles( + relationship(passive_deletes=True, back_populates='ticket_participant'), + grants_via={'account': {'scanner'}}, ) ticket_events: Mapped[list[TicketEvent]] = relationship( @@ -295,18 +309,6 @@ class TicketParticipant( 'scanner': {'read': {'email'}}, } - def roles_for( - self, actor: Account | None = None, anchors: Sequence = () - ) -> LazyRoleSet: - roles = super().roles_for(actor, anchors) - if actor is not None: - if actor == self.participant: - roles.add('member') - cx = db.session.get(ContactExchange, (actor.id, self.id)) - if cx is not None: - roles.add('scanner') - return roles - @property def avatar(self) -> str: return ( @@ -343,10 +345,7 @@ def upsert( ) -> TicketParticipant: ticket_participant = cls.get(current_project, current_email) accountemail = AccountEmail.get(current_email) - if accountemail is not None: - participant = accountemail.account - else: - participant = None + participant = accountemail.account if accountemail is not None else None if ticket_participant is not None: ticket_participant.participant = participant ticket_participant._set_fields(fields) # pylint: disable=protected-access @@ -372,7 +371,9 @@ 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[CheckinParticipantProtocol]: """ Return ticket participant details as a comma separated string. diff --git a/funnel/models/types.py b/funnel/models/types.py index 95f9c5cf1..66ba382d8 100644 --- a/funnel/models/types.py +++ b/funnel/models/types.py @@ -24,19 +24,19 @@ 'smallint', 'timestamp', 'timestamp_now', - 'unicode', - 'text', - 'jsonb', - 'jsonb_dict', - 'char2', - 'char3', - 'str3', - 'str16', + 'Unicode', + 'Text', + 'Jsonb', + 'JsonbDict', + 'Char2', + 'Char3', + 'Str3', + 'Str16', ] -unicode: TypeAlias = Annotated[str, mapped_column(sa.Unicode())] -text: TypeAlias = Annotated[str, mapped_column(sa.UnicodeText())] -jsonb: TypeAlias = Annotated[ +Unicode: TypeAlias = Annotated[str, mapped_column(sa.Unicode())] +Text: TypeAlias = Annotated[str, mapped_column(sa.UnicodeText())] +Jsonb: TypeAlias = Annotated[ dict, sa_orm.mapped_column( # FIXME: mutable_json_type assumes `dict|list`, not just `dict` @@ -45,7 +45,7 @@ ) ), ] -jsonb_dict: TypeAlias = Annotated[ +JsonbDict: TypeAlias = Annotated[ dict, sa_orm.mapped_column( # FIXME: mutable_json_type assumes `dict|list`, not just `dict` @@ -58,7 +58,7 @@ ] # Specialised types -char2: TypeAlias = Annotated[str, mapped_column(sa.CHAR(2))] -char3: TypeAlias = Annotated[str, mapped_column(sa.CHAR(3))] -str3: TypeAlias = Annotated[str, mapped_column(sa.Unicode(3))] -str16: TypeAlias = Annotated[str, mapped_column(sa.Unicode(16))] +Char2: TypeAlias = Annotated[str, mapped_column(sa.CHAR(2))] +Char3: TypeAlias = Annotated[str, mapped_column(sa.CHAR(3))] +Str3: TypeAlias = Annotated[str, mapped_column(sa.Unicode(3))] +Str16: TypeAlias = Annotated[str, mapped_column(sa.Unicode(16))] diff --git a/funnel/models/typing.py b/funnel/models/typing.py index 2358802b4..8ac7fdc27 100644 --- a/funnel/models/typing.py +++ b/funnel/models/typing.py @@ -10,7 +10,7 @@ from sqlalchemy import Table from sqlalchemy.orm import Mapped, declared_attr -from coaster.sqlalchemy import LazyRoleSet, QueryProperty +from coaster.sqlalchemy import LazyRoleSet, QueryProperty, RoleAccessProxy from coaster.utils import InspectableSet __all__ = [ @@ -64,6 +64,10 @@ def actors_with( self, roles: Iterable[str], with_role: bool = False ) -> Iterator[Account | tuple[Account, str]]: ... + def current_access( + self, datasets: Sequence[str] | None = None + ) -> RoleAccessProxy: ... + class ModelIdProtocol( ModelTimestampProtocol, ModelUrlProtocol, ModelRoleProtocol, Protocol diff --git a/funnel/models/update.py b/funnel/models/update.py index 4bd3a6db4..66427ebef 100644 --- a/funnel/models/update.py +++ b/funnel/models/update.py @@ -7,8 +7,8 @@ from typing import Any, Self from baseframe import __ -from coaster.sqlalchemy import LazyRoleSet, StateManager, auto_init_default, with_roles -from coaster.utils import LabeledEnum +from coaster.sqlalchemy import StateManager, auto_init_default, role_check, with_roles +from coaster.utils import LabeledEnum, NameTitle from .account import Account from .base import ( @@ -31,28 +31,34 @@ ) from .project import Project -__all__ = ['Update'] +__all__ = ['Update', 'VISIBILITY_STATE'] class UPDATE_STATE(LabeledEnum): # noqa: N801 - DRAFT = (1, 'draft', __("Draft")) - PUBLISHED = (2, 'published', __("Published")) - DELETED = (3, 'deleted', __("Deleted")) + DRAFT = (1, NameTitle('draft', __("Draft"))) + PUBLISHED = (2, NameTitle('published', __("Published"))) + DELETED = (3, NameTitle('deleted', __("Deleted"))) class VISIBILITY_STATE(LabeledEnum): # noqa: N801 - PUBLIC = (1, 'public', __("Public")) - RESTRICTED = (2, 'restricted', __("Restricted")) + PUBLIC = (1, NameTitle('public', __("Public"))) + PARTICIPANTS = (2, NameTitle('participants', __("Participants"))) + MEMBERS = (3, NameTitle('members', __("Members"))) class Update(UuidMixin, BaseScopedIdNameMixin[int, Account], Model): __tablename__ = 'update' + # FIXME: Why is this a state? There's no state change in the product design. + # It's a permanent subtype identifier _visibility_state: Mapped[int] = sa_orm.mapped_column( 'visibility_state', sa.SmallInteger, StateManager.check_constraint( - 'visibility_state', VISIBILITY_STATE, sa.SmallInteger + 'visibility_state', + VISIBILITY_STATE, + sa.SmallInteger, + name='update_visibility_state_check', ), default=VISIBILITY_STATE.PUBLIC, nullable=False, @@ -65,7 +71,9 @@ class Update(UuidMixin, BaseScopedIdNameMixin[int, Account], Model): _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.SmallInteger, - StateManager.check_constraint('state', UPDATE_STATE, sa.SmallInteger), + StateManager.check_constraint( + 'state', UPDATE_STATE, sa.SmallInteger, name='update_state_check' + ), default=UPDATE_STATE.DRAFT, nullable=False, index=True, @@ -93,45 +101,30 @@ class Update(UuidMixin, BaseScopedIdNameMixin[int, Account], Model): datasets={'primary'}, grants_via={ None: { + 'reader': {'project_reader'}, # Project reader is NOT update reader 'editor': {'editor', 'project_editor'}, - 'participant': {'reader', 'project_participant'}, - 'crew': {'reader', 'project_crew'}, + 'participant': {'project_participant'}, + 'account_follower': {'account_follower'}, + 'account_member': {'account_member'}, + 'member_participant': {'member_participant'}, + 'crew': {'project_crew', 'reader'}, } }, ) parent: Mapped[Project] = sa_orm.synonym('project') - # Relationship to project that exists only when the Update is not restricted, for - # the purpose of inheriting the account_participant role. We do this because - # RoleMixin does not have a mechanism for conditional grant of roles. A relationship - # marked as `grants_via` will always grant the role unconditionally, so the only - # control at the moment is to make the relationship itself conditional. The affected - # mechanism is not `roles_for` but `actors_with`, which is currently not meant to be - # redefined in a subclass - _project_when_unrestricted: Mapped[Project] = with_roles( - relationship( - viewonly=True, - uselist=False, - primaryjoin=sa.and_( - project_id == Project.id, _visibility_state == VISIBILITY_STATE.PUBLIC - ), - ), - grants_via={None: {'account_participant': 'account_participant'}}, - ) - body, body_text, body_html = MarkdownCompositeDocument.create( 'body', nullable=False ) - #: Update number, for Project updates, assigned when the update is published + #: Update serial number, only assigned when the update is published number: Mapped[int | None] = with_roles( sa_orm.mapped_column(default=None), read={'all'} ) - #: Like pinned tweets. You can keep posting updates, - #: but might want to pin an update from a week ago. + #: Pin an update above future updates is_pinned: Mapped[bool] = with_roles( - sa_orm.mapped_column(default=False), read={'all'} + sa_orm.mapped_column(default=False), read={'all'}, write={'editor'} ) published_by_id: Mapped[int | None] = sa_orm.mapped_column( @@ -185,6 +178,8 @@ class Update(UuidMixin, BaseScopedIdNameMixin[int, Account], Model): 'body_text', weights={'name': 'A', 'title': 'A', 'body_text': 'B'}, regconfig='english', + # FIXME: Search preview will give partial access to Update.body even if the + # user does not have the necessary 'reader' role hltext=lambda: sa.func.concat_ws( visual_field_delimiter, Update.title, Update.body_html ), @@ -198,6 +193,8 @@ class Update(UuidMixin, BaseScopedIdNameMixin[int, Account], Model): 'read': {'name', 'title', 'urls'}, 'call': {'features', 'visibility_state', 'state', 'url_for'}, }, + 'project_crew': {'read': {'body'}}, + 'editor': {'write': {'title', 'body'}, 'read': {'body'}}, 'reader': {'read': {'body'}}, } @@ -213,10 +210,8 @@ class Update(UuidMixin, BaseScopedIdNameMixin[int, Account], Model): 'edited_at', 'created_by', 'is_pinned', - 'is_restricted', 'is_currently_restricted', - 'visibility_label', - 'state_label', + 'visibility', 'urls', 'uuid_b58', }, @@ -231,10 +226,8 @@ class Update(UuidMixin, BaseScopedIdNameMixin[int, Account], Model): 'edited_at', 'created_by', 'is_pinned', - 'is_restricted', 'is_currently_restricted', - 'visibility_label', - 'state_label', + 'visibility', 'urls', 'uuid_b58', }, @@ -249,17 +242,47 @@ def __repr__(self) -> str: """Represent :class:`Update` as a string.""" return f'' - @property - def visibility_label(self) -> str: - return self.visibility_state.label.title - - with_roles(visibility_label, read={'all'}) + @role_check('reader') + def has_reader_role( + self, actor: Account | None, _anchors: Sequence[Any] = () + ) -> bool: + """Check if the given actor is a reader based on the Update's visibility.""" + if not self.state.PUBLISHED: + # Update must be published to allow anyone other than crew to read + return False + if self.visibility_state.PUBLIC: + return True + roles = self.roles_for(actor) + if self.visibility_state.PARTICIPANTS: + return 'project_participant' in roles + if self.visibility_state.MEMBERS: + return 'account_member' in roles + + raise RuntimeError("This update has an unexpected state") + + # 'reader' is a non-enumerated role, like `all`, `auth` and `anon` @property - def state_label(self) -> str: - return self.state.label.title - - with_roles(state_label, read={'all'}) + def visibility(self) -> str: + """Return visibility state name.""" + return self.visibility_state.label.name + + @visibility.setter + def visibility(self, value: str) -> None: + """Set visibility state (interim until visibility as state is resolved).""" + # FIXME: Move to using an Enum so we don't reproduce the enumeration here + match value: + case 'public': + vstate = VISIBILITY_STATE.PUBLIC + case 'participants': + vstate = VISIBILITY_STATE.PARTICIPANTS + case 'members': + vstate = VISIBILITY_STATE.MEMBERS + case _: + raise ValueError("Unknown visibility state") + self._visibility_state = vstate # type: ignore[assignment] + + with_roles(visibility, read={'all'}, write={'editor'}) state.add_conditional_state( 'UNPUBLISHED', @@ -311,51 +334,17 @@ def delete(self, actor: Account) -> None: @with_roles(call={'editor'}) @state.transition(state.DELETED, state.DRAFT) - def undo_delete(self) -> None: + def undelete(self) -> None: self.deleted_by = None self.deleted_at = None - @with_roles(call={'editor'}) - @visibility_state.transition(visibility_state.RESTRICTED, visibility_state.PUBLIC) - def make_public(self) -> None: - pass - - @with_roles(call={'editor'}) - @visibility_state.transition(visibility_state.PUBLIC, visibility_state.RESTRICTED) - def make_restricted(self) -> None: - pass - - @property - def is_restricted(self) -> bool: - return bool(self.visibility_state.RESTRICTED) - - @is_restricted.setter - def is_restricted(self, value: bool) -> None: - if value and self.visibility_state.PUBLIC: - self.make_restricted() - elif not value and self.visibility_state.RESTRICTED: - self.make_public() - - with_roles(is_restricted, read={'all'}) - @property def is_currently_restricted(self) -> bool: - return self.is_restricted and not self.current_roles.reader + """Check if this update is not available for the current user.""" + return not self.current_roles.reader with_roles(is_currently_restricted, read={'all'}) - def roles_for( - self, actor: Account | None = None, anchors: Sequence = () - ) -> LazyRoleSet: - roles = super().roles_for(actor, anchors) - if not self.visibility_state.RESTRICTED: - # Everyone gets reader role when the post is not restricted. - # If it is, 'reader' must be mapped from 'participant' in the project, - # specified above in the grants_via annotation on project. - roles.add('reader') - - return roles - @classmethod def all_published_public(cls) -> Query[Self]: return cls.query.join(Project).filter( diff --git a/funnel/models/utils.py b/funnel/models/utils.py index 552eefbb1..8a77ddda1 100644 --- a/funnel/models/utils.py +++ b/funnel/models/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import suppress from typing import Literal, NamedTuple, TypeVar, overload import phonenumbers @@ -62,7 +63,7 @@ def getuser(name: str, anchor: bool = False) -> Account | AccountAndAnchor | Non 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('~'): + if name.startswith(('@', '~')): name = name[1:] # If there's an '@' in the middle, treat as an email address elif '@' in name: @@ -87,7 +88,7 @@ def getuser(name: str, anchor: bool = False) -> Account | AccountAndAnchor | Non return None else: # If it was not an email address or an @username, check if it's a phone number - try: + with suppress(phonenumbers.NumberParseException): # Assume unprefixed numbers to be a local number in one of our supported # regions, in order of priority. Also see # :func:`~funnel.models.phone_number.parse_phone_number` for similar @@ -104,10 +105,8 @@ def getuser(name: str, anchor: bool = False) -> Account | AccountAndAnchor | Non if anchor: return AccountAndAnchor(accountphone.account, accountphone) return accountphone.account - # No matching account phone? Continue to trying as a username - except phonenumbers.NumberParseException: - # This was not a parsable phone number. Continue to trying as a username - pass + # No matching account phone? Not parseable as a phone number? Continue to trying + # as a username # Last guess: username user = Account.get(name=name) @@ -242,7 +241,7 @@ def do_migrate_table(table: sa.Table) -> bool: # Now check for multi-column indexes for constraint in table.constraints: - if isinstance(constraint, (PrimaryKeyConstraint, UniqueConstraint)): + if isinstance(constraint, PrimaryKeyConstraint | UniqueConstraint): for column in constraint.columns: if column in target_columns: # The target column (typically account_id) is part of a unique @@ -291,7 +290,7 @@ def do_migrate_table(table: sa.Table) -> bool: old_instance, new_instance ) session.flush() - if isinstance(result, (list, tuple, set)): + if isinstance(result, list | tuple | set): migrated_tables.update(result) migrated_tables.add(model.__table__.name) except IncompleteUserMigrationError: diff --git a/funnel/models/venue.py b/funnel/models/venue.py index 23e6a1f00..f9cf2abf4 100644 --- a/funnel/models/venue.py +++ b/funnel/models/venue.py @@ -21,7 +21,7 @@ from .project import Project from .project_membership import project_child_role_map, project_child_role_set -__all__ = ['Venue', 'VenueRoom'] +__all__ = ['Venue', 'VenueRoom', 'project_venue_primary_table'] class Venue(UuidMixin, BaseScopedNameMixin[int, Account], CoordinatesMixin, Model): @@ -188,7 +188,9 @@ def scoped_name(self) -> str: return f'{self.parent.name}/{self.name}' -add_primary_relationship(Project, 'primary_venue', Venue, 'project', 'project_id') +project_venue_primary_table = add_primary_relationship( + Project, 'primary_venue', Venue, 'project', 'project_id' +) with_roles(Project.primary_venue, read={'all'}, datasets={'primary', 'without_parent'}) diff --git a/funnel/models/video_mixin.py b/funnel/models/video_mixin.py index c53fc4eab..729716456 100644 --- a/funnel/models/video_mixin.py +++ b/funnel/models/video_mixin.py @@ -75,10 +75,8 @@ def make_video_url(video_source: str, video_id: str) -> str: @declarative_mixin class VideoMixin: - video_id: Mapped[str | None] = sa_orm.mapped_column(sa.UnicodeText, nullable=True) - video_source: Mapped[str | None] = sa_orm.mapped_column( - sa.UnicodeText, nullable=True - ) + video_id: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True) + video_source: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True) @property def video_url(self) -> str | None: diff --git a/funnel/proxies/__init__.py b/funnel/proxies/__init__.py index 4c9908fd6..4ef41ab8e 100644 --- a/funnel/proxies/__init__.py +++ b/funnel/proxies/__init__.py @@ -2,7 +2,9 @@ from flask import Flask -from .request import request_wants, response_varies +from .request import RequestWants, request_wants, response_varies + +__all__ = ['RequestWants', 'request_wants'] def init_app(app: Flask) -> None: diff --git a/funnel/proxies/request.py b/funnel/proxies/request.py index 38c66548d..16158269f 100644 --- a/funnel/proxies/request.py +++ b/funnel/proxies/request.py @@ -4,7 +4,7 @@ from collections.abc import Callable from functools import wraps -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, TypeVar, cast from flask import has_request_context, request from flask.globals import request_ctx @@ -13,12 +13,18 @@ from ..typing import ResponseType, T -__all__ = ['request_wants'] +__all__ = ['request_wants', 'RequestWants'] + + +RequestWantsType = TypeVar('RequestWantsType', bound='RequestWants') def test_uses( *headers: str, -) -> Callable[[Callable[[RequestWants], T]], cached_property[T | None]]: +) -> Callable[ + [Callable[[RequestWantsType], T]], # pyright: ignore[reportInvalidTypeVarUse] + cached_property[T | None], +]: """ Identify HTTP headers accessed in this test, to be set in the response Vary header. @@ -26,9 +32,9 @@ def test_uses( method into a cached property. """ - def decorator(f: Callable[[RequestWants], T]) -> cached_property[T | None]: + def decorator(f: Callable[[RequestWantsType], T]) -> cached_property[T | None]: @wraps(f) - def wrapper(self: RequestWants) -> T | None: + def wrapper(self: RequestWantsType) -> T | None: self.response_vary.update(headers) if not has_request_context(): return None @@ -57,7 +63,7 @@ def __init__(self) -> None: def __bool__(self) -> bool: return has_request_context() - # --- request_wants tests ---------------------------------------------------------- + # MARK: request_wants tests -------------------------------------------------------- @test_uses('Accept') def json(self) -> bool: @@ -116,7 +122,7 @@ def hx_prompt(self) -> str | None: """Content of user prompt in HTMX.""" return request.environ.get('HTTP_HX_PROMPT') - # --- End of request_wants tests --------------------------------------------------- + # MARK: End of request_wants tests ------------------------------------------------- if TYPE_CHECKING: diff --git a/funnel/registry.py b/funnel/registry.py index 013f9981d..275238635 100644 --- a/funnel/registry.py +++ b/funnel/registry.py @@ -61,7 +61,7 @@ def resource_auth_error(message: str) -> Response: ) def decorator( - f: Callable[[AuthToken, MultiDict, MultiDict], Any] + f: Callable[[AuthToken, MultiDict, MultiDict], Any], ) -> Callable[[], ReturnResponse]: @wraps(f) def wrapper() -> ReturnResponse: @@ -96,26 +96,27 @@ def wrapper() -> ReturnResponse: # Read once to avoid reparsing below tokenscope = set(authtoken.effective_scope) wildcardscope = usescope.split('/', 1)[0] + '/*' - if not (authtoken.auth_client.trusted and '*' in tokenscope): - # If a trusted client has '*' in token scope, all good, - # else check further - if (usescope not in tokenscope) and ( - wildcardscope not in tokenscope - ): - # Client doesn't have access to this scope either - # directly or via a wildcard - return resource_auth_error( - _("Token does not provide access to this resource") - ) + if ( + not (authtoken.auth_client.trusted and '*' in tokenscope) + and (usescope not in tokenscope) + and (wildcardscope not in tokenscope) + ): + # If the client is trusted but doesn’t have '*' in token scope, or + # the client doesn't have access to this scope either directly or + # via a wildcard, raise an error + return resource_auth_error( + _("Token does not provide access to this resource") + ) if trusted and not authtoken.auth_client.trusted: return resource_auth_error( _("This resource can only be accessed by trusted clients") ) # All good. Return the result value try: + # pylint: disable=possibly-used-before-assignment result = f(authtoken, args, request.files) response = jsonify({'status': 'ok', 'result': result}) - except Exception as exc: # noqa: B902 # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except exception_catchall.send(exc) response = jsonify( { diff --git a/funnel/templates/about.html.jinja2 b/funnel/templates/about.html.jinja2 index c192d98a0..e884652ed 100644 --- a/funnel/templates/about.html.jinja2 +++ b/funnel/templates/about.html.jinja2 @@ -1,9 +1,9 @@ {% extends "layout.html.jinja2" %} {% block title %}{% trans %}About Hasgeek{% endtrans %}{% endblock title %} -{%- from "macros.html.jinja2" import page_footer %} +{%- from "macros.html.jinja2" import about_page_footer %} {% block pageheaders %} - + {% endblock pageheaders %} {% block contenthead %} @@ -47,7 +47,7 @@ {%- endblock contentwrapper %} {% block basefooter %} - {{ page_footer() }} + {{ about_page_footer('about') }} {% endblock basefooter %} {% block footerscripts %} diff --git a/funnel/templates/account.html.jinja2 b/funnel/templates/account.html.jinja2 index 6fb90efed..e43d9431c 100644 --- a/funnel/templates/account.html.jinja2 +++ b/funnel/templates/account.html.jinja2 @@ -7,10 +7,10 @@ {%- block pageheaders %} + href="{{ webpack('css/login_form.css') }}"/> + href="{{ webpack('css/account.css') }}"/> {%- endblock pageheaders %} {% block bodyattrs -%} class="bg-primary tabs-navbar" @@ -271,9 +271,9 @@
-
- - - -
-
-
-

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

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

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

+ {{ faicon(icon='trash-alt', icon_size='subhead', baseline=false, css_class="mui--align-middle mui--text-hyperlink") }} + + {%- endif -%} +
+ + {%- endwith %} + {%- endfor %} + +
+
-
-
+
+
+
+

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

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

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

+
+
+
- {%- endblock basecontent %} {% block footerscripts -%} - + {{ ajaxform('email-primary-form', request) }} {{ ajaxform('phone-primary-form', request) }} +{% block innerscripts %} + -{% endblock footerscripts %} +{% endblock innerscripts %} diff --git a/funnel/templates/account_formlayout.html.jinja2 b/funnel/templates/account_formlayout.html.jinja2 index 2a8167981..8facf9898 100644 --- a/funnel/templates/account_formlayout.html.jinja2 +++ b/funnel/templates/account_formlayout.html.jinja2 @@ -4,9 +4,9 @@ {% block title %}{{ title }}{% endblock title %} {% block layoutheaders %} - - - + + + {% endblock layoutheaders %} {% block bodyattrs %}class="login-page no-sticky-header"{% endblock bodyattrs %} @@ -49,14 +49,14 @@ {% block footerscripts %} {{ widget_ext_scripts(form) }} - + - + {{ ajaxform(ref_id=ref_id, request=request, force=ajax) }} - {%- if form and 'recaptcha' in form %} + {%- if form and form.recaptcha is defined and not config.get('RECAPTCHA_DISABLED') %} {% block recaptcha %}{{ recaptcha(ref_id=ref_id) }}{% endblock recaptcha %} {%- endif %} {% endblock footerscripts %} diff --git a/funnel/templates/account_organizations.html.jinja2 b/funnel/templates/account_organizations.html.jinja2 index c653401c1..85331ffbe 100644 --- a/funnel/templates/account_organizations.html.jinja2 +++ b/funnel/templates/account_organizations.html.jinja2 @@ -55,7 +55,7 @@

{% if orgmem.is_owner %} {% trans %}Owner{% endtrans %} - {% else %} + {% elif orgmem.is_admin %} {% trans %}Admin{% endtrans %} {% endif %}

diff --git a/funnel/templates/account_saved.html.jinja2 b/funnel/templates/account_saved.html.jinja2 index 8a14ddf83..fc65fc112 100644 --- a/funnel/templates/account_saved.html.jinja2 +++ b/funnel/templates/account_saved.html.jinja2 @@ -28,5 +28,5 @@ {% endblock basecontent %} {% block footerscripts %} - + {% endblock footerscripts %} diff --git a/funnel/templates/ajaxform.html.jinja2 b/funnel/templates/ajaxform.html.jinja2 index ae84b8fe7..e04b3078a 100644 --- a/funnel/templates/ajaxform.html.jinja2 +++ b/funnel/templates/ajaxform.html.jinja2 @@ -1,5 +1,5 @@
- + {% from "forms.html.jinja2" import renderform, ajaxform, widget_ext_scripts, widgetscripts %} {%- from "macros.html.jinja2" import alertbox -%} {% block pageheaders %} @@ -24,14 +24,14 @@ {{ widget_ext_scripts(form) }} {% block innerscripts %} - + {%- if with_chrome -%} {{ ajaxform(ref_id=ref_id, request=request, force=true) }} {%- endif -%} - {%- if form and 'recaptcha' in form %} + {%- if form and form.recaptcha is defined %} {% block recaptcha %}{% endblock recaptcha %} {%- endif %} {% endblock innerscripts %} diff --git a/funnel/templates/auth_client_index.html.jinja2 b/funnel/templates/auth_client_index.html.jinja2 index d64ebcc8e..6abe3b4c6 100644 --- a/funnel/templates/auth_client_index.html.jinja2 +++ b/funnel/templates/auth_client_index.html.jinja2 @@ -19,13 +19,14 @@ {% for auth_client in auth_clients %} - {%- set link = auth_client.url_for() %} + {%- with link = auth_client.url_for() %} {{ loop.index }} {{ auth_client.title }} {{ auth_client.account.pickername }} {{ auth_client.website }} + {%- endwith %} {% else %} {% trans %}No applications have been registered{% endtrans %} diff --git a/funnel/templates/contact.html.jinja2 b/funnel/templates/contact.html.jinja2 index 0e9f4e149..a339c022c 100644 --- a/funnel/templates/contact.html.jinja2 +++ b/funnel/templates/contact.html.jinja2 @@ -1,9 +1,9 @@ {% extends "layout.html.jinja2" %} {% block title %}{{ page.title }}{% endblock title %} -{%- from "macros.html.jinja2" import page_footer %} +{%- from "macros.html.jinja2" import about_page_footer %} {% block pageheaders %} - + {% endblock pageheaders %} {% block contenthead %} @@ -32,7 +32,7 @@ {%- endblock contentwrapper %} {% block basefooter %} - {{ page_footer() }} + {{ about_page_footer('contact') }} {% endblock basefooter %} {% block footerscripts %} diff --git a/funnel/templates/contacts.html.jinja2 b/funnel/templates/contacts.html.jinja2 index 1557acc31..1056c56bf 100644 --- a/funnel/templates/contacts.html.jinja2 +++ b/funnel/templates/contacts.html.jinja2 @@ -4,7 +4,7 @@ {% block title %}{% trans %}My contacts{% endtrans %}{% endblock title %} {% block pageheaders %} - + {% endblock pageheaders %} {% block bodyattrs %}class="bg-primary tabs-navbar"{% endblock bodyattrs %} @@ -89,5 +89,5 @@ {% endblock basecontent %} {% block footerscripts %} - + {% endblock footerscripts %} diff --git a/funnel/templates/followers_list.html.jinja2 b/funnel/templates/followers_list.html.jinja2 new file mode 100644 index 000000000..000e64854 --- /dev/null +++ b/funnel/templates/followers_list.html.jinja2 @@ -0,0 +1,22 @@ +{%- from "macros.html.jinja2" import useravatar %} + +{%- for account in accounts %} +
  • +
    + {{ useravatar(account) }} +
    +

    {{ account.pickername }}

    +
    +
    +
  • +{%- endfor %} +{% if next_page %} +
  • +
  • +{% endif %} diff --git a/funnel/templates/formlayout.html.jinja2 b/funnel/templates/formlayout.html.jinja2 index 88156fa33..f4d00d3de 100644 --- a/funnel/templates/formlayout.html.jinja2 +++ b/funnel/templates/formlayout.html.jinja2 @@ -2,8 +2,8 @@ {% block title %}{{ title }}{% endblock title %} {% block layoutheaders %} - - + + {% endblock layoutheaders %} {% block contentwrapper %} @@ -21,9 +21,9 @@ {% endblock serviceworker %} {% block footerscripts %} - + {%- if autosave %} - + + diff --git a/funnel/templates/index.html.jinja2 b/funnel/templates/index.html.jinja2 index f18854c81..ab8edf543 100644 --- a/funnel/templates/index.html.jinja2 +++ b/funnel/templates/index.html.jinja2 @@ -1,11 +1,11 @@ {% extends "profile_layout.html.jinja2" %} {% block title %}{{ config['SITE_TITLE'] }}{% endblock title %} -{%- from "macros.html.jinja2" import faicon, calendarwidget, saveprojectform, profilecard %} +{%- from "macros.html.jinja2" import faicon, calendarwidget, profilecard %} {%- block pageheaders %} - + {% if featured_project and featured_project.schedule_start_at -%} - + {%- endif %} @@ -90,12 +90,11 @@ {% endif %} {{ open_cfp_section(open_cfp_projects) }} - {{ all_projects_section(all_projects) }} {{ past_projects_section() }} {% endblock basecontent %} {% block innerscripts %} - + @@ -108,11 +108,12 @@ {% raw %} + {% endblock footerscripts %} diff --git a/funnel/templates/labels_form.html.jinja2 b/funnel/templates/labels_form.html.jinja2 index 285481b79..f88efeba7 100644 --- a/funnel/templates/labels_form.html.jinja2 +++ b/funnel/templates/labels_form.html.jinja2 @@ -4,7 +4,7 @@ {% block title %}{{ title }}{% endblock title %} {% block pageheaders %} - + {% endblock pageheaders %} {% macro labelformfields(lform, subform=false, empty=false) %} @@ -72,7 +72,7 @@ {% block innerscripts %} - + - + {% block serviceworker %} + + + + +{%- endblock pageheaders %} + +{% block bodyattrs %}class="bg-primary mobile-header"{% endblock bodyattrs %} + +{% block contenthead %} +{% endblock contenthead %} + +{% block baseheadline %} + {{ profile_header(profile, class="mui--hidden-xs mui--hidden-sm", current_page="calendar", title=_("Calendar")) }} +{% endblock baseheadline %} + +{% block basecontent %} +
    +
    +
    + {% raw %} +
    +
    +
    +

    Projects

    +
    +
    +
    +
    + + + + + + +
    +
    +

    + {{ event.title }} +

    +

    {{ propertyVal(event, 'date_str') }} {{ propertyVal(event, 'time') }}

    +
    + Accepting proposalsMember accessFree +
    +

    {{ propertyVal(event, 'venue') }}

    +
    +
    +
    +
    +
    +
    +
    + + {{ date }} + +
    + +
    +
    +

    {{ gettext('Format') }}

    +
    +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    + +

    {{ gettext('Access') }}

    +
    +
    +
    +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    + +

    {{ gettext('Proposal Submissions') }}

    +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    + {% endraw %} +
    +
    +
    +{% endblock basecontent %} + +{% block innerscripts %} + +{% endblock innerscripts %} diff --git a/funnel/templates/profile_followers.html.jinja2 b/funnel/templates/profile_followers.html.jinja2 new file mode 100644 index 000000000..7758d6fa1 --- /dev/null +++ b/funnel/templates/profile_followers.html.jinja2 @@ -0,0 +1,63 @@ +{%- if not request_wants.html_fragment -%} + {% extends "profile_layout.html.jinja2" %} +{%- else -%} + {% extends "followers_list.html.jinja2" %} +{% endif %} + +{%- block pageheaders %} + + + {% if featured_project and featured_project.schedule_start_at -%} + + {%- endif %} + + + +{%- endblock pageheaders %} + +{% block bodyattrs %}class="bg-primary no-sticky-header mobile-header"{% endblock bodyattrs %} + +{% block baseheadline %} + {{ profile_header(profile, class="mui--hidden-xs mui--hidden-sm", current_page="followers", title=_("Followers")) }} +{% endblock baseheadline %} + +{% block basecontent %} +
    +
    +
    +
    +
    +

    {% trans %}Followers{% endtrans %}

    +
      +
    • +
    +
    +
    +
    +
    +
    +{% endblock basecontent %} diff --git a/funnel/templates/profile_following.html.jinja2 b/funnel/templates/profile_following.html.jinja2 new file mode 100644 index 000000000..41786e9ee --- /dev/null +++ b/funnel/templates/profile_following.html.jinja2 @@ -0,0 +1,63 @@ +{%- if not request_wants.html_fragment -%} + {% extends "profile_layout.html.jinja2" %} +{%- else -%} + {% extends "followers_list.html.jinja2" %} +{% endif %} + +{%- block pageheaders %} + + + {% if featured_project and featured_project.schedule_start_at -%} + + {%- endif %} + + + +{%- endblock pageheaders %} + +{% block bodyattrs %}class="bg-primary no-sticky-header mobile-header"{% endblock bodyattrs %} + +{% block baseheadline %} + {{ profile_header(profile, class="mui--hidden-xs mui--hidden-sm", current_page="following", title=_("Following")) }} +{% endblock baseheadline %} + +{% block basecontent %} +
    +
    +
    +
    +
    +

    {% trans %}Following{% endtrans %}

    +
      +
    • +
    +
    +
    +
    +
    +
    +{% endblock basecontent %} diff --git a/funnel/templates/profile_layout.html.jinja2 b/funnel/templates/profile_layout.html.jinja2 index 857d6b316..67aa5c1e7 100644 --- a/funnel/templates/profile_layout.html.jinja2 +++ b/funnel/templates/profile_layout.html.jinja2 @@ -1,5 +1,5 @@ {% extends "layout.html.jinja2" %} -{%- from "macros.html.jinja2" import img_size, saveprojectform, calendarwidget, projectcard, video_thumbnail %} +{%- from "macros.html.jinja2" import faicon, img_size, calendarwidget, projectcard, video_thumbnail, profileavatar %} {%- from "js/schedule.js.jinja2" import schedule_template %} {% block title %}{{ profile.title }}{% endblock title %} @@ -75,20 +75,8 @@
    - - {%- if featured_project.account.logo_url.url %} - {{ featured_project.account.title }} - {% else %} - {{ featured_project.account.title }} - {% endif %} - - {{ featured_project.account.title }} - + {{ profileavatar(featured_project.account, css_class='margin-left') }}
    - {%- if not current_auth.is_anonymous %} - {% set save_form_id = "spotlight_spfm_desktop_" + featured_project.uuid_b58 %} -
    {{ saveprojectform(featured_project, formid=save_form_id) }}
    - {% endif %}

    {{ featured_project.title }}

    {{ featured_project.tagline }}

    @@ -110,10 +98,6 @@ {{ featured_project.account.title }}
    - {%- if not current_auth.is_anonymous %} - {% set save_form_id = "spotlight_spfm_mobile_" + featured_project.uuid_b58 %} -
    {{ saveprojectform(featured_project, formid=save_form_id) }}
    - {% endif %}
    - {% if featured_project and featured_project.schedule_start_at -%} + {%- if featured_project and featured_project.features.show_featured_schedule %}
    -
    +
    {{ schedule_template() }}
    - {% endif %} + {%- endif %}
    @@ -181,7 +165,7 @@
      {% for project in upcoming_projects %}
    • - {{ projectcard(project, save_form_id_prefix='upcoming_spf_') }} + {{ projectcard(project) }}
    • {%- endfor -%}
    @@ -203,13 +187,13 @@ {% endif %}
      {% for project in open_cfp_projects %} -
    • - {{ projectcard(project, include_calendar=false, save_form_id_prefix='open_spf_') }} + {{ projectcard(project, include_calendar=false) }}
    • {%- endfor -%}
    - {% if open_cfp_projects|length > 4 %} + {% if open_cfp_projects|length > 3 %}
    @@ -219,24 +203,68 @@ {% endif %} {% endmacro %} -{% macro all_projects_section(all_projects, heading=true) %} - {% if all_projects %} +{% macro membership_section(membership_project, profile) %} + {% if membership_project %}
    - {% if heading %}
    -

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

    +

    {% trans %}Membership{% endtrans %}

    +

    {% trans %}Becoming a member is the best way to connect with industry practitioners and like minded geeks from around the world. Members gain exclusive access to curated discussions and high quality content that is produced here, on a monthly basis.{% endtrans %}

    +
    +
    + +
    +
    +
    + {% if membership_project.bg_image.url %} + {% trans %}Membership{% endtrans %} + + {% else %} + {% trans %}Membership{% endtrans %} + {% endif %} +
    +
    +
    +
    + {% if membership_project.bg_image.url %} + {% trans %}Membership{% endtrans %} + + {% else %} + {% trans %}Membership{% endtrans %} + {% endif %} +
    +
    +
    {{ membership_project.description }}
    +
    +
    +
    {{ membership_project.description }}
    + {% if profile and profile.current_roles.member %} +
    + {% elif membership_project.features.show_tickets %} +
    + +
    +
    +
    + {{ faicon(icon='times', baseline=false, icon_size='title') }} +

    {% trans %}Loading…{% endtrans %}

    +
    +
    +
    +
    + {%- endif %} +
    - {% endif %} -
      - {% for project in all_projects %} -
    • - {{ projectcard(project, save_form_id_prefix='all_spf_') }} -
    • - {%- endfor -%} -
    {% endif %} @@ -276,14 +304,14 @@
    -

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

    +

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

    - + @@ -302,14 +330,14 @@
    -

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

    +

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

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

      -

      Loading

      +

      {% trans %}Loading{% endtrans %}

    • @@ -319,6 +347,44 @@
    {% endmacro %} +{% macro profile_header_buttons(profile) %} +
    + {% if profile.features.new_project() %} + {{ faicon(icon='plus', icon_size='caption') }} {% trans %}New project{% endtrans %} + {% elif profile.features.make_public() %} + {% trans %}Make account public{% endtrans %} + + {% endif %} + + {%- if current_auth.is_anonymous %} + {% trans %}Follow{% endtrans %} + {%- elif profile != current_auth.user and not profile.features.is_private() %} + + {% if not hide_unfollow %} + + {% endif %} + + {%- endif %} + +
    +{% endmacro %} + {% macro profile_header(profile, class="", current_page='profile', title="") %}
    @@ -332,7 +398,7 @@
    - {%- if profile.banner_image_url.url %} + {%- if profile.is_verified and profile.banner_image_url.url %} {{ profile.title }} {% else %} {{ profile.title }} @@ -341,7 +407,7 @@
    - {%- if profile.current_roles.admin %} + {%- if profile.current_roles.admin and profile.is_verified %} {{ faicon(icon='camera', icon_size='body2', css_class="profile__banner__icon") }} {% trans %}Add cover photo{% endtrans %} {% endif %}
    - {% if profile.features.new_project() %} - {{ faicon(icon='plus', icon_size='caption') }} {% trans %}New project{% endtrans %} - {% elif profile.features.make_public() %} - {% trans %}Make account public{% endtrans %} - - {% endif %} + {{ profile_header_buttons(profile) }} {%- if profile.current_roles.admin %} -
    {{ profile_admin_buttons(profile) }}
    +
    {{ profile_admin_buttons(profile) }}
    {% endif %}
    @@ -401,19 +446,17 @@
      {%- if profile.joined_at %} -
    • {{ faicon(icon='history') }} {% trans date=profile.joined_at|date(format='MMM YYYY')%}Joined {{ date }}{% endtrans %}
    • +
    • {{ faicon(icon='history') }} {% trans date=profile.joined_at|date(format='MMM YYYY') %}Joined {{ date }}{% endtrans %}
    • {%- endif %} {%- if profile.website %}
    • {{ faicon(icon='globe') }} {{ profile.website|cleanurl }}
    • {%- endif %} + {%- if profile.tagline %} +
    • {{ profile.tagline }}
    • + {%- endif %}
    -
    {{ profile.description }}
    - {% if profile.features.new_project() %} - - {% elif profile.features.make_public() %} - - {% endif %} + {{ profile_header_buttons(profile) }}
    @@ -425,11 +468,19 @@
    @@ -452,8 +503,9 @@ {% block footerscripts %} {% block innerscripts %}{% endblock innerscripts %} + {% if featured_project and featured_project.schedule_start_at -%} - + - + - + + + + +{% block footerinnerscripts %} + {% endblock footerinnerscripts %} diff --git a/funnel/templates/project_schedule.html.jinja2 b/funnel/templates/project_schedule.html.jinja2 index e15ca1fd8..894dcad32 100644 --- a/funnel/templates/project_schedule.html.jinja2 +++ b/funnel/templates/project_schedule.html.jinja2 @@ -35,7 +35,7 @@ {%- block pageheaders -%} + href="{{ webpack('css/schedule.css') }}"/> {%- if project.start_at %} + + diff --git a/funnel/templates/project_submissions.html.jinja2 b/funnel/templates/project_submissions.html.jinja2 index 6bb054de3..51350e5bd 100644 --- a/funnel/templates/project_submissions.html.jinja2 +++ b/funnel/templates/project_submissions.html.jinja2 @@ -8,7 +8,7 @@ {% block title %}{% trans %}Submissions{% endtrans %}{% endblock title %} {% block pageheaders %} - + {% endblock pageheaders %} {% block left_col %} @@ -57,7 +57,7 @@ {% endblock left_col %} {% block footerinnerscripts %} - + + + {{ ajaxform('rsvp-form', request) }} + + + + diff --git a/funnel/templates/session_view_popup.html.jinja2 b/funnel/templates/session_view_popup.html.jinja2 index b7a126c54..ab4a647d0 100644 --- a/funnel/templates/session_view_popup.html.jinja2 +++ b/funnel/templates/session_view_popup.html.jinja2 @@ -2,42 +2,42 @@
    {%- endif %} - {% if session.proposal %} -

    {{ faicon(icon='presentation') }} {% trans %}View submission for this session{% endtrans %}

    + {% if project_session.proposal %} +

    {{ faicon(icon='presentation') }} {% trans %}View submission for this session{% endtrans %}

    {% endif %} - {% if session.views.video or session.proposal %} + {% if project_session.views.video or project_session.proposal %} {% endif %} - {% if session.description %} + {% if project_session.description %} {% endif %}
    diff --git a/funnel/templates/signup_form.html.jinja2 b/funnel/templates/signup_form.html.jinja2 index 46114eb0f..f62db668d 100644 --- a/funnel/templates/signup_form.html.jinja2 +++ b/funnel/templates/signup_form.html.jinja2 @@ -2,7 +2,7 @@ {% extends "account_formlayout.html.jinja2" %} {%- else -%} {% extends "ajaxform.html.jinja2" %} - + {% endif %} {% from "password_login_form.html.jinja2" import sociallogin %} @@ -20,5 +20,5 @@ {% endblock form %} {% block afterloginbox %} - + {% endblock afterloginbox %} diff --git a/funnel/templates/siteadmin_generate_shortlinks.html.jinja2 b/funnel/templates/siteadmin_generate_shortlinks.html.jinja2 index 62e2b1f84..75de48c6d 100644 --- a/funnel/templates/siteadmin_generate_shortlinks.html.jinja2 +++ b/funnel/templates/siteadmin_generate_shortlinks.html.jinja2 @@ -3,7 +3,7 @@ {% block title %}{% trans %}Get shortlink with additional tags{% endtrans %}{% endblock title %} {% block pageheaders %} - + {% endblock pageheaders %} {% block content %} @@ -82,5 +82,5 @@ {% endblock content %} {% block footerscripts %} - + {% endblock footerscripts %} diff --git a/funnel/templates/submission.html.jinja2 b/funnel/templates/submission.html.jinja2 index bd294ac64..31732f508 100644 --- a/funnel/templates/submission.html.jinja2 +++ b/funnel/templates/submission.html.jinja2 @@ -15,10 +15,10 @@ {%- block pageheaders -%} + href="{{ webpack('css/submission.css') }}"/> + href="{{ webpack('css/comments.css') }}"/> {%- endblock pageheaders -%} {%- block bodyattrs -%} class="bg-accent no-sticky-header mobile-header proposal-page subproject-page {% if proposal.views.video or proposal.session and proposal.session.views.video %}mobile-hide-livestream{% endif %}" @@ -365,7 +365,7 @@ {%- if not membership.is_uncredited %}
    - {{ useravatar(membership.member) }} + {{ useravatar(membership.member, css_class="margin-right", follow_btn_css="flex-order-last margin-left") }}

    {{ membership.member.fullname }} @@ -440,8 +440,8 @@

    {% endblock left_col %} {% block footerinnerscripts %} - - + + - + + + {{ ajaxform(ref_id='form-update', request=request) }} - +
    {% trans %}Date{% endtrans %}{% trans %}Project{% endtrans %}{% trans %}Session{% endtrans %} {% trans %}Location{% endtrans %}