diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 126c39fb8..c389cbabf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: - id: pip-compile-multi-verify files: ^requirements/.*\.(in|txt)$ - repo: https://github.com/pypa/pip-audit - rev: v2.6.1 + rev: v2.6.2 hooks: - id: pip-audit args: [ @@ -51,7 +51,7 @@ repos: - id: pyupgrade args: ['--keep-runtime-typing', '--py311-plus'] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.6 + rev: v0.1.9 hooks: - id: ruff args: ['--fix', '--exit-non-zero-on-fix'] @@ -91,13 +91,13 @@ repos: - toml - tomli - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort additional_dependencies: - tomli - repo: https://github.com/psf/black - rev: 23.11.0 + rev: 23.12.1 hooks: - id: black # Mypy is temporarily disabled until the SQLAlchemy 2.0 migration is complete @@ -126,7 +126,7 @@ repos: - id: flake8 additional_dependencies: *flake8deps - repo: https://github.com/PyCQA/pylint - rev: v3.0.1 + rev: v3.0.3 hooks: - id: pylint args: [ @@ -138,7 +138,7 @@ repos: additional_dependencies: - tomli - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 + rev: 1.7.6 hooks: - id: bandit language_version: python3 @@ -208,11 +208,10 @@ repos: - id: forbid-tabs - id: remove-tabs - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 + rev: v4.0.0-alpha.8 hooks: - id: prettier - args: - ['--single-quote', '--trailing-comma', 'es5', '--end-of-line', 'lf'] + args: ['--single-quote', '--trailing-comma', 'es5', '--end-of-line', 'lf'] exclude: funnel/templates/js/ - repo: https://github.com/ducminh-phan/reformat-gherkin rev: v3.0.1 diff --git a/.prettierrc.js b/.prettierrc.js index c90450753..1aed56ad4 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -2,4 +2,5 @@ module.exports = { endOfLine: 'lf', singleQuote: true, trailingComma: 'es5', + printWidth: 88, }; diff --git a/funnel/__init__.py b/funnel/__init__.py index eeef7cee0..210bd508e 100644 --- a/funnel/__init__.py +++ b/funnel/__init__.py @@ -26,14 +26,14 @@ #: Main app for hasgeek.com app = Flask(__name__, instance_relative_config=True) -app.name = 'funnel' +app.name = 'funnel' # pyright: ignore[reportGeneralTypeIssues] app.config['SITE_TITLE'] = __("Hasgeek") -#: Shortlink app at has.gy +#: Short link app at has.gy shortlinkapp = Flask(__name__, static_folder=None, instance_relative_config=True) -shortlinkapp.name = 'shortlink' +shortlinkapp.name = 'shortlink' # pyright: ignore[reportGeneralTypeIssues] #: Unsubscribe app at bye.li unsubscribeapp = Flask(__name__, static_folder=None, instance_relative_config=True) -unsubscribeapp.name = 'unsubscribe' +unsubscribeapp.name = 'unsubscribe' # pyright: ignore[reportGeneralTypeIssues] all_apps = [app, shortlinkapp, unsubscribeapp] @@ -79,7 +79,7 @@ views, cli, ) -from .models import db, sa # isort:skip # pylint: disable=wrong-import-position +from .models import db, sa_orm # isort:skip # --- Configuration--------------------------------------------------------------------- @@ -92,9 +92,6 @@ each_app, ['py', 'env'], env_prefix=['FLASK', f'APP_{each_app.name.upper()}'] ) -# Legacy additional config for the main app (pending deprecation) -coaster.app.load_config_from_file(app, 'hasgeekapp.py') - # Force specific config settings, overriding deployment config shortlinkapp.config['SERVER_NAME'] = app.config['SHORTLINK_DOMAIN'] if app.config.get('UNSUBSCRIBE_DOMAIN'): @@ -147,7 +144,12 @@ geoip.geoip.init_app(app) # Baseframe is required for apps with UI ('funnel' theme is registered above) -baseframe.init_app(app, requires=['funnel'], theme='funnel', error_handlers=False) +baseframe.init_app( + app, + requires=['funnel'], + theme='funnel', # type: ignore[arg-type] + error_handlers=False, +) # Initialize available login providers from app config loginproviders.init_app(app) @@ -195,7 +197,7 @@ views.siteadmin.init_rq_dashboard() -# --- Serve static files with Whitenoise ----------------------------------------------- +# --- Serve static files with WhiteNoise ----------------------------------------------- app.wsgi_app = WhiteNoise( # type: ignore[method-assign] app.wsgi_app, root=app.static_folder, prefix=app.static_url_path @@ -208,4 +210,4 @@ # Database model loading (from Funnel or extensions) is complete. # Configure database mappers now, before the process is forked for workers. -sa.orm.configure_mappers() +sa_orm.configure_mappers() diff --git a/funnel/assets/sass/pages/schedule.scss b/funnel/assets/sass/pages/schedule.scss index 3d2e60d50..6ce6869fb 100644 --- a/funnel/assets/sass/pages/schedule.scss +++ b/funnel/assets/sass/pages/schedule.scss @@ -1,3 +1,4 @@ +@use 'sass:math'; @import '../base/variable'; @import '../components/draggablebox'; @import '../components/collapsible'; @@ -43,7 +44,7 @@ .schedule__row__column__content__description { clear: both; - padding-top: $mui-grid-padding/4; + padding-top: math.div($mui-grid-padding, 4); padding-bottom: $mui-grid-padding; word-break: break-word; @@ -62,7 +63,7 @@ h5 { font-size: 12px; margin-top: 0; - margin-bottom: $mui-grid-padding/4; + margin-bottom: math.div($mui-grid-padding, 4); } } @@ -109,8 +110,8 @@ .schedule__row--sticky { display: flex; overflow-x: auto; - position: sticky; position: -webkit-sticky; + position: sticky; top: 0; // header height in home page order: 1; z-index: 2; @@ -131,7 +132,7 @@ min-height: 50px; min-width: 60%; width: 100% !important; - padding: $mui-grid-padding/2; + padding: math.div($mui-grid-padding, 2); } .schedule__row__column--header.js-tab-active { diff --git a/funnel/cli/periodic/notification.py b/funnel/cli/periodic/notification.py index 5d0c62315..c5454c7c7 100644 --- a/funnel/cli/periodic/notification.py +++ b/funnel/cli/periodic/notification.py @@ -5,7 +5,7 @@ from datetime import timedelta from ... import models -from ...models import db, sa +from ...models import db, sa, sa_orm from ...views.notification import dispatch_notification from . import periodic @@ -27,7 +27,7 @@ def project_starting_alert() -> None: # 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 + # sa_orm.defer(column) and sa_orm.noload(relationship). There are none as of this # commit. for project in ( models.Project.starting_at( @@ -35,7 +35,7 @@ def project_starting_alert() -> None: timedelta(minutes=5), timedelta(minutes=60), ) - .options(sa.orm.load_only(models.Project.uuid)) + .options(sa_orm.load_only(models.Project.uuid)) .all() ): dispatch_notification( diff --git a/funnel/cli/refresh/markdown.py b/funnel/cli/refresh/markdown.py index 17e9d0086..2fa846732 100644 --- a/funnel/cli/refresh/markdown.py +++ b/funnel/cli/refresh/markdown.py @@ -9,7 +9,7 @@ import rich.progress from ... import models -from ...models import MarkdownModelUnion, db, sa +from ...models import MarkdownModelUnion, db, sa_orm from . import refresh _M = TypeVar('_M', bound=MarkdownModelUnion) @@ -60,7 +60,7 @@ def reparse(self, config: str | None = None, obj: _M | None = None) -> None: ) iter_list = ( self.model.query.order_by(self.model.id) - .options(sa.orm.load_only(*load_columns)) + .options(sa_orm.load_only(*load_columns)) .yield_per(10) ) iter_total = self.model.query.count() diff --git a/funnel/devtest.py b/funnel/devtest.py index ae00c228e..e3bfea795 100644 --- a/funnel/devtest.py +++ b/funnel/devtest.py @@ -1,4 +1,5 @@ """Support for development and testing environments.""" +# pyright: reportGeneralTypeIssues=false from __future__ import annotations @@ -13,7 +14,7 @@ import weakref from collections.abc import Callable, Iterable from secrets import token_urlsafe -from typing import Any, NamedTuple, Protocol +from typing import Any, NamedTuple, Protocol, cast from flask import Flask @@ -29,7 +30,7 @@ # Pytest `live_server` fixture used for end-to-end tests. Fork on macOS is not # compatible with the Objective C framework. If you have a framework Python build and # experience crashes, try setting the environment variable -# OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES +# `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` mpcontext = multiprocessing.get_context('fork') # --- Development and testing app multiplexer ------------------------------------------ @@ -160,7 +161,7 @@ def install_mock(func: Callable, mock: Callable) -> None: # Use weakref to dereference func from local namespace func = weakref.ref(func) gc.collect() - refs = gc.get_referrers(func()) # type: ignore[misc] + refs = gc.get_referrers(func()) # type: ignore[misc] # Typeshed says not callable # Recover func from the weakref so we can do an `is` match in referrers func = func() # type: ignore[misc] for ref in refs: @@ -170,6 +171,10 @@ def install_mock(func: Callable, mock: Callable) -> None: for key, value in ref.items(): if value is func: ref[key] = mock + else: + raise RuntimeError( + f"Can't patch {func.__qualname__} in unknown reference type {ref!r}" + ) def _prepare_subprocess( @@ -267,9 +272,9 @@ def __init__( self.mock_transports = mock_transports manager = mpcontext.Manager() - self.calls: CapturedCalls = manager.Namespace() - self.calls.email = manager.list() - self.calls.sms = manager.list() + self.calls = cast(CapturedCalls, manager.Namespace()) + self.calls.email = cast(list[CapturedEmail], manager.list()) + self.calls.sms = cast(list[CapturedSms], manager.list()) def start(self) -> None: """Start worker in a separate process.""" diff --git a/funnel/forms/account.py b/funnel/forms/account.py index 0060c8be5..1182ffd3e 100644 --- a/funnel/forms/account.py +++ b/funnel/forms/account.py @@ -19,6 +19,7 @@ PASSWORD_MIN_LENGTH, Account, Anchor, + User, check_password_strength, getuser, ) @@ -158,7 +159,7 @@ class PasswordForm(forms.Form): """Form to validate a user's password, for password-gated sudo actions.""" __expects__ = ('edit_user',) - edit_user: Account + edit_user: User password = forms.PasswordField( __("Password"), @@ -181,7 +182,7 @@ class PasswordPolicyForm(forms.Form): __expects__ = ('edit_user',) __returns__ = ('password_strength', 'is_weak', 'warning', 'suggestions') - edit_user: Account + edit_user: User password_strength: int | None = None is_weak: bool | None = None warning: str | None = None @@ -252,7 +253,7 @@ class PasswordCreateForm(forms.Form): __returns__ = ('password_strength',) __expects__ = ('edit_user',) - edit_user: Account + edit_user: User password_strength: int | None = None password = forms.PasswordField( @@ -334,7 +335,7 @@ class PasswordChangeForm(forms.Form): __returns__ = ('password_strength',) __expects__ = ('edit_user',) - edit_user: Account + edit_user: User password_strength: int | None = None old_password = forms.PasswordField( @@ -473,7 +474,7 @@ class UsernameAvailableForm(forms.Form): """Form to check for whether a username is available to use.""" __expects__ = ('edit_user',) - edit_user: Account + edit_user: User username = forms.StringField( __("Username"), @@ -519,7 +520,7 @@ class NewEmailAddressForm( """Form to add a new email address to an account.""" __expects__ = ('edit_user',) - edit_user: Account + edit_user: User email = forms.EmailField( __("Email address"), @@ -566,7 +567,7 @@ class NewPhoneForm(EnableNotificationsDescriptionProtoMixin, forms.RecaptchaForm """Form to add a new mobile number (SMS-capable) to an account.""" __expects__ = ('edit_user',) - edit_user: Account + edit_user: User phone = forms.TelField( __("Phone number"), diff --git a/funnel/forms/auth_client.py b/funnel/forms/auth_client.py index bc2b5db87..7a5597ae3 100644 --- a/funnel/forms/auth_client.py +++ b/funnel/forms/auth_client.py @@ -12,6 +12,7 @@ AuthClient, AuthClientCredential, AuthClientPermissions, + User, valid_name, ) from .helpers import strip_filters @@ -29,7 +30,8 @@ class AuthClientForm(forms.Form): """Register a new OAuth client application.""" __returns__ = ('account',) - account: Account | None = None + edit_user: User + account: Account title = forms.StringField( __("Application title"), @@ -127,7 +129,7 @@ def _urls_match(self, url1: str, url2: str) -> bool: def validate_redirect_uri(self, field: forms.Field) -> None: """Validate redirect URI points to the website for confidential clients.""" if self.confidential.data and not self._urls_match( - self.website.data, field.data + self.website.data or '', field.data ): raise forms.validators.ValidationError( _("The scheme, domain and port must match that of the website URL") diff --git a/funnel/forms/helpers.py b/funnel/forms/helpers.py index cbebf8c30..e40ea4883 100644 --- a/funnel/forms/helpers.py +++ b/funnel/forms/helpers.py @@ -48,7 +48,7 @@ class AccountSelectField(forms.AutocompleteField): def _value(self) -> str: """Return value for HTML rendering.""" if self.data is not None: - return self.data.name + return self.data.name or '' return '' def process_formdata(self, valuelist: Sequence[str]) -> None: @@ -83,9 +83,7 @@ def __init__(self, purpose: Literal['use', 'claim', 'register']) -> None: def __call__(self, form: forms.Form, field: forms.Field) -> None: # Get actor (from form, or current_auth.actor) - actor: User | None = None - if hasattr(form, 'edit_user'): - actor = form.edit_user + actor: User | None = getattr(form, 'edit_user', None) if actor is None: actor = current_auth.actor @@ -174,9 +172,7 @@ def __init__(self, purpose: Literal['use', 'claim', 'register']) -> None: def __call__(self, form: forms.Form, field: forms.Field) -> None: # Get actor (from existing obj, or current_auth.actor) - actor: User | None = None - if hasattr(form, 'edit_user'): - actor = form.edit_user + actor: User | None = getattr(form, 'edit_user', None) if actor is None: actor = current_auth.actor diff --git a/funnel/forms/login.py b/funnel/forms/login.py index a4111157d..9df9f7e73 100644 --- a/funnel/forms/login.py +++ b/funnel/forms/login.py @@ -9,9 +9,7 @@ from ..models import ( PASSWORD_MAX_LENGTH, Account, - AccountEmail, - AccountEmailClaim, - AccountPhone, + Anchor, EmailAddress, EmailAddressBlockedError, LoginSession, @@ -78,7 +76,7 @@ def __call__(self, form, field) -> None: if not field.data: # Use getattr for when :meth:`LoginForm.validate_username` is skipped if getattr(form, 'anchor', None) is not None: - # If user has an anchor, we can allow login to proceed passwordless + # If user has an anchor, we can allow login to proceed password-less # using an OTP or email link raise LoginWithOtp() if ( @@ -123,7 +121,7 @@ class LoginForm(forms.RecaptchaForm): __returns__ = ('user', 'anchor', 'weak_password', 'new_email', 'new_phone') user: Account | None = None - anchor: AccountEmail | AccountEmailClaim | AccountPhone | None = None + anchor: Anchor | None = None weak_password: bool | None = None new_email: str | None = None new_phone: str | None = None @@ -195,7 +193,7 @@ def validate_password(self, field: forms.Field) -> None: """Validate password if provided.""" # If there is already an error in the password field, don't bother validating. # This will be a `Length` validation error, but that one unfortunately does not - # raise `StopValidation`. If the length is off, we can end rightaway. + # raise `StopValidation`. If the length is off, we can end right away. if field.errors: return @@ -244,7 +242,7 @@ def validate_password(self, field: forms.Field) -> None: # supports both outcomes. # `check_password_strength(password).is_weak` is a bool - self.weak_password: bool = check_password_strength(field.data).is_weak + self.weak_password = check_password_strength(field.data).is_weak @Account.forms('logout') diff --git a/funnel/forms/notification.py b/funnel/forms/notification.py index bc8342fde..f0609355d 100644 --- a/funnel/forms/notification.py +++ b/funnel/forms/notification.py @@ -101,18 +101,6 @@ class TransportLabels: disabled_main=__("Disabled all WhatsApp notifications"), disabled=__("Disabled this WhatsApp notification"), ), - 'signal': TransportLabels( - title=__("Signal"), - requirement=__("To enable, add your Signal number"), - requirement_action=lambda: url_for('add_phone'), - unsubscribe_form=__("Notify me on Signal (beta)"), - unsubscribe_description=__("Uncheck this to disable all Signal notifications"), - switch=__("Signal notifications"), - enabled_main=__("Enabled selected Signal notifications"), - enabled=__("Enabled this Signal notification"), - disabled_main=__("Disabled all Signal notifications"), - disabled=__("Disabled this Signal notification"), - ), } diff --git a/funnel/forms/organization.py b/funnel/forms/organization.py index 077b96454..ee6f712ae 100644 --- a/funnel/forms/organization.py +++ b/funnel/forms/organization.py @@ -9,7 +9,7 @@ from baseframe import _, __, forms -from ..models import Account, Team +from ..models import Account, Team, User __all__ = ['OrganizationForm', 'TeamForm'] @@ -19,7 +19,7 @@ class OrganizationForm(forms.Form): """Form for an organization's name and title.""" __expects__: Iterable[str] = ('edit_user',) - edit_user: Account + edit_user: User edit_obj: Account | None title = forms.StringField( diff --git a/funnel/forms/project.py b/funnel/forms/project.py index e015947f5..01879d6ac 100644 --- a/funnel/forms/project.py +++ b/funnel/forms/project.py @@ -197,7 +197,11 @@ class ProjectNameForm(forms.Form): ), validators=[ forms.validators.DataRequired(), - forms.validators.Length(max=Project.__name_length__), + forms.validators.Length( + max=Project.__name_length__ + if Project.__name_length__ is not None + else -1 + ), forms.validators.ValidName( __( "This URL contains unsupported characters. It can contain lowercase" @@ -354,7 +358,7 @@ def set_queries(self) -> None: # options in the form even without an Rsvp instance. self.transition.choices = [ (transition_name, getattr(Rsvp, transition_name)) - for transition_name in Rsvp.state.statemanager.transitions + for transition_name in Rsvp.state.transitions ] diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index eb243c69d..7392d5834 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -4,7 +4,11 @@ from __future__ import annotations +from typing import ClassVar + import sqlalchemy as sa +import sqlalchemy.exc as sa_exc +import sqlalchemy.orm as sa_orm from flask_sqlalchemy import SQLAlchemy from sqlalchemy.dialects import postgresql from sqlalchemy.ext.hybrid import hybrid_property @@ -38,12 +42,14 @@ class Model(ModelBase, DeclarativeBase): """Base for all models.""" + __table__: ClassVar[sa.Table] __with_timezone__ = True class GeonameModel(ModelBase, DeclarativeBase): """Base for geoname models.""" + __table__: ClassVar[sa.Table] __bind_key__ = 'geoname' __with_timezone__ = True @@ -51,7 +57,7 @@ class GeonameModel(ModelBase, DeclarativeBase): # This must be set _before_ any of the models using db.Model are imported TimestampMixin.__with_timezone__ = True -db = SQLAlchemy(query_class=Query, metadata=Model.metadata) # type: ignore[arg-type] +db: SQLAlchemy = SQLAlchemy(query_class=Query, metadata=Model.metadata) # type: ignore[arg-type] Model.init_flask_sqlalchemy(db) GeonameModel.init_flask_sqlalchemy(db) diff --git a/funnel/models/account.py b/funnel/models/account.py index 4c7e40466..5cf9f93f1 100644 --- a/funnel/models/account.py +++ b/funnel/models/account.py @@ -1,12 +1,14 @@ """Account model with subtypes, and account-linked personal data models.""" +# pylint: disable=unnecessary-lambda,invalid-unary-operand-type +# pyright: reportGeneralTypeIssues=false from __future__ import annotations import hashlib import itertools -from collections.abc import Iterable, Iterator -from datetime import datetime, timedelta -from typing import ClassVar, Literal, cast, overload +from collections.abc import Iterable, Iterator, Sequence +from datetime import datetime +from typing import TYPE_CHECKING, ClassVar, Literal, Self, cast, overload from uuid import UUID import phonenumbers @@ -22,6 +24,7 @@ from baseframe import __ from coaster.sqlalchemy import ( + DynamicAssociationProxy, LazyRoleSet, RoleMixin, StateManager, @@ -45,11 +48,12 @@ TSVectorType, UrlType, UuidMixin, - backref, db, hybrid_property, relationship, sa, + sa_exc, + sa_orm, ) from .email_address import EmailAddress, EmailAddressMixin from .helpers import ( @@ -123,6 +127,37 @@ def __eq__(self, other: object) -> sa.ColumnElement[bool]: # type: ignore[overr return sa.false() +# --- Tables --------------------------------------------------------------------------- + +team_membership = sa.Table( + 'team_membership', + Model.metadata, + sa.Column( + 'account_id', + sa.Integer, + sa.ForeignKey('account.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'team_id', + sa.Integer, + sa.ForeignKey('team.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'created_at', + sa.TIMESTAMP(timezone=True), + nullable=False, + default=sa.func.utcnow(), + ), +) + + +# --- Models --------------------------------------------------------------------------- + + class Account(UuidMixin, BaseMixin, Model): """Account model.""" @@ -142,17 +177,17 @@ class Account(UuidMixin, BaseMixin, Model): reserved_names: ClassVar[set[str]] = RESERVED_NAMES - type_: Mapped[str] = sa.orm.mapped_column('type', sa.CHAR(1), nullable=False) + type_: Mapped[str] = sa_orm.mapped_column('type', sa.CHAR(1), nullable=False) #: Join date for users and organizations (skipped for placeholders) - joined_at: Mapped[datetime | None] = sa.orm.mapped_column( + joined_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: The optional "username", used in the URL stub, with a unique constraint on the #: lowercase value (defined in __table_args__ below) name: Mapped[str | None] = with_roles( - sa.orm.mapped_column( + sa_orm.mapped_column( sa.Unicode(__name_length__), sa.CheckConstraint("name <> ''"), nullable=True, @@ -162,43 +197,43 @@ class Account(UuidMixin, BaseMixin, Model): #: The account's title (user's fullname) title: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.Unicode(__title_length__), default='', nullable=False), + sa_orm.mapped_column(sa.Unicode(__title_length__), default='', nullable=False), read={'all'}, ) #: Alias title as user's fullname - fullname: Mapped[str] = sa.orm.synonym('title') + fullname: Mapped[str] = sa_orm.synonym('title') #: Alias name as user's username - username: Mapped[str] = sa.orm.synonym('name') + username: Mapped[str] = sa_orm.synonym('name') #: Argon2 or Bcrypt hash of the user's password - pw_hash: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode, nullable=True) + pw_hash: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True) #: Timestamp for when the user's password last changed - pw_set_at: Mapped[datetime | None] = sa.orm.mapped_column( + pw_set_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Expiry date for the password (to prompt user to reset it) - pw_expires_at: Mapped[datetime | None] = sa.orm.mapped_column( + pw_expires_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: User's preferred/last known timezone timezone: Mapped[BaseTzInfo | None] = with_roles( - sa.orm.mapped_column(TimezoneType(backend='pytz'), nullable=True), + sa_orm.mapped_column(TimezoneType(backend='pytz'), nullable=True), read={'owner'}, ) #: Update timezone automatically from browser activity - auto_timezone: Mapped[bool] = sa.orm.mapped_column( + auto_timezone: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, default=True, nullable=False ) #: User's preferred/last known locale locale: Mapped[Locale | None] = with_roles( - sa.orm.mapped_column(LocaleType, nullable=True), read={'owner'} + sa_orm.mapped_column(LocaleType, nullable=True), read={'owner'} ) #: Update locale automatically from browser activity - auto_locale: Mapped[bool] = sa.orm.mapped_column( + auto_locale: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, default=True, nullable=False ) #: User's state code (active, suspended, merged, deleted) - _state: Mapped[int] = sa.orm.mapped_column( + _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.SmallInteger, StateManager.check_constraint('state', ACCOUNT_STATE), @@ -206,36 +241,36 @@ class Account(UuidMixin, BaseMixin, Model): default=ACCOUNT_STATE.ACTIVE, ) #: Account state manager - state = StateManager('_state', ACCOUNT_STATE, doc="Account state") + state = StateManager['Account']('_state', ACCOUNT_STATE, doc="Account state") #: Other accounts that were merged into this account old_accounts: AssociationProxy[list[Account]] = association_proxy( 'oldids', 'old_account' ) - _profile_state: Mapped[int] = sa.orm.mapped_column( + _profile_state: Mapped[int] = sa_orm.mapped_column( 'profile_state', sa.SmallInteger, StateManager.check_constraint('profile_state', PROFILE_STATE), nullable=False, default=PROFILE_STATE.AUTO, ) - profile_state = StateManager( + profile_state = StateManager['Account']( '_profile_state', PROFILE_STATE, doc="Current state of the account profile" ) - tagline: Mapped[str | None] = sa.orm.mapped_column( + tagline: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode, sa.CheckConstraint("tagline <> ''"), nullable=True ) description, description_text, description_html = MarkdownCompositeDocument.create( 'description', default='', nullable=False ) - website: Mapped[furl | None] = sa.orm.mapped_column( + website: Mapped[furl | None] = sa_orm.mapped_column( UrlType, sa.CheckConstraint("website <> ''"), nullable=True ) - logo_url: Mapped[furl | None] = sa.orm.mapped_column( + logo_url: Mapped[furl | None] = sa_orm.mapped_column( ImgeeType, sa.CheckConstraint("logo_url <> ''"), nullable=True ) - banner_image_url: Mapped[furl | None] = sa.orm.mapped_column( + banner_image_url: Mapped[furl | None] = sa_orm.mapped_column( ImgeeType, sa.CheckConstraint("banner_image_url <> ''"), nullable=True ) @@ -244,22 +279,22 @@ class Account(UuidMixin, BaseMixin, Model): #: Protected accounts cannot be deleted is_protected: Mapped[bool] = with_roles( - immutable(sa.orm.mapped_column(sa.Boolean, default=False, nullable=False)), + immutable(sa_orm.mapped_column(sa.Boolean, default=False, nullable=False)), read={'owner', 'admin'}, ) #: Verified accounts get listed on the home page and are not considered throwaway #: accounts for spam control. There are no other privileges at this time is_verified: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, default=False, nullable=False, index=True), + sa_orm.mapped_column(sa.Boolean, default=False, nullable=False, index=True), read={'all'}, ) #: Revision number maintained by SQLAlchemy, starting at 1 revisionid: Mapped[int] = with_roles( - sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + sa_orm.mapped_column(sa.Integer, nullable=False), read={'all'} ) - search_vector: Mapped[str] = sa.orm.mapped_column( + search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( 'title', 'name', @@ -284,7 +319,7 @@ class Account(UuidMixin, BaseMixin, Model): deferred=True, ) - name_vector: Mapped[str] = sa.orm.mapped_column( + name_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( 'title', 'name', @@ -295,6 +330,577 @@ class Account(UuidMixin, BaseMixin, Model): deferred=True, ) + # --- Backrefs + + # account.py: + oldid: Mapped[AccountOldId] = relationship( + primaryjoin=lambda: sa_orm.foreign(AccountOldId.id) == Account.uuid, + uselist=False, + back_populates='old_account', + ) + oldids: Mapped[list[AccountOldId]] = relationship( + foreign_keys=lambda: AccountOldId.account_id, back_populates='account' + ) + teams: Mapped[list[Team]] = relationship( + foreign_keys=lambda: Team.account_id, + order_by=lambda: sa.func.lower(Team.title), + back_populates='account', + ) + member_teams: Mapped[list[Team]] = relationship( + secondary='team_membership', back_populates='users' + ) + emails: Mapped[list[AccountEmail]] = relationship(back_populates='account') + emailclaims: Mapped[list[AccountEmailClaim]] = relationship( + back_populates='account' + ) + phones: Mapped[list[AccountPhone]] = relationship(back_populates='account') + externalids: Mapped[list[AccountExternalId]] = relationship( + back_populates='account' + ) + + # account_membership.py + memberships: DynamicMapped[AccountMembership] = relationship( + foreign_keys=lambda: AccountMembership.account_id, + lazy='dynamic', + passive_deletes=True, + back_populates='account', + ) + active_admin_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(), + viewonly=True, + ), + grants_via={'member': {'admin', 'owner'}}, + ) + + active_owner_memberships: DynamicMapped[AccountMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + sa_orm.remote(AccountMembership.account_id) == Account.id, + AccountMembership.is_active, + AccountMembership.is_owner.is_(True), + ), + viewonly=True, + ) + + active_invitations: DynamicMapped[AccountMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + sa_orm.remote(AccountMembership.account_id) == Account.id, + AccountMembership.is_invite, + AccountMembership.revoked_at.is_(None), + ), + viewonly=True, + ) + + owner_users = with_roles( + DynamicAssociationProxy['Account']('active_owner_memberships', 'member'), + read={'all'}, + ) + admin_users = with_roles( + DynamicAssociationProxy['Account']('active_admin_memberships', 'member'), + read={'all'}, + ) + + organization_admin_memberships: DynamicMapped[AccountMembership] = relationship( + lazy='dynamic', + foreign_keys=lambda: AccountMembership.member_id, + viewonly=True, + ) + + noninvite_organization_admin_memberships: DynamicMapped[ + AccountMembership + ] = relationship( + lazy='dynamic', + foreign_keys=lambda: AccountMembership.member_id, + primaryjoin=lambda: sa.and_( + sa_orm.remote(AccountMembership.member_id) == Account.id, + ~AccountMembership.is_invite, + ), + viewonly=True, + ) + + active_organization_admin_memberships: DynamicMapped[ + AccountMembership + ] = relationship( + lazy='dynamic', + foreign_keys=lambda: AccountMembership.member_id, + primaryjoin=lambda: sa.and_( + sa_orm.remote(AccountMembership.member_id) == Account.id, + AccountMembership.is_active, + ), + viewonly=True, + ) + + active_organization_owner_memberships: DynamicMapped[ + AccountMembership + ] = relationship( + lazy='dynamic', + foreign_keys=lambda: AccountMembership.member_id, + primaryjoin=lambda: sa.and_( + sa_orm.remote(AccountMembership.member_id) == Account.id, + AccountMembership.is_active, + AccountMembership.is_owner.is_(True), + ), + viewonly=True, + ) + + active_organization_invitations: DynamicMapped[AccountMembership] = relationship( + lazy='dynamic', + foreign_keys=lambda: AccountMembership.member_id, + primaryjoin=lambda: sa.and_( + sa_orm.remote(AccountMembership.member_id) == Account.id, + AccountMembership.is_invite, + AccountMembership.revoked_at.is_(None), + ), + viewonly=True, + ) + + organizations_as_owner = DynamicAssociationProxy['Account']( + 'active_organization_owner_memberships', 'account' + ) + + organizations_as_admin = DynamicAssociationProxy['Account']( + 'active_organization_admin_memberships', 'account' + ) + + # auth_client.py + clients: Mapped[AuthClient] = relationship(back_populates='account') + authtokens: DynamicMapped[AuthToken] = relationship( + lazy='dynamic', back_populates='account' + ) + client_permissions: Mapped[list[AuthClientPermissions]] = relationship( + back_populates='account' + ) + + # comment.py + comments: DynamicMapped[Comment] = relationship( + lazy='dynamic', back_populates='_posted_by' + ) + # commentset_membership.py + active_commentset_memberships: DynamicMapped[CommentsetMembership] = relationship( + lazy='dynamic', + primaryjoin='''and_( + CommentsetMembership.member_id == Account.id, + CommentsetMembership.is_active, + )''', + viewonly=True, + ) + subscribed_commentsets = DynamicAssociationProxy['Commentset']( + 'active_commentset_memberships', 'commentset' + ) + + # contact_exchange.py + scanned_contacts: DynamicMapped[ContactExchange] = relationship( + lazy='dynamic', + order_by='ContactExchange.scanned_at.desc()', + passive_deletes=True, + back_populates='account', + ) + + # login_session.py + all_login_sessions: DynamicMapped[LoginSession] = relationship( + lazy='dynamic', back_populates='account' + ) + active_login_sessions: DynamicMapped[LoginSession] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + LoginSession.account_id == Account.id, + LoginSession.accessed_at > sa.func.utcnow() - LOGIN_SESSION_VALIDITY_PERIOD, + LoginSession.revoked_at.is_(None), + ), + order_by=lambda: LoginSession.accessed_at.desc(), + viewonly=True, + ) + + # mailer.py + mailers: Mapped[list[Mailer]] = relationship( + back_populates='user', order_by=lambda: Mailer.updated_at.desc() + ) + + # moderation.py + moderator_reports: DynamicMapped[CommentModeratorReport] = relationship( + lazy='dynamic', back_populates='reported_by' + ) + + # notification.py + all_notifications: DynamicMapped[NotificationRecipient] = with_roles( + relationship( + lazy='dynamic', + order_by=lambda: NotificationRecipient.created_at.desc(), + viewonly=True, + ), + read={'owner'}, + ) + + notification_preferences: Mapped[dict[str, NotificationPreferences]] = relationship( + collection_class=sa_orm.attribute_keyed_dict('notification_type'), + back_populates='account', + ) + + # This relationship is wrapped in a property that creates it on first access + _main_notification_preferences: Mapped[NotificationPreferences] = relationship( + primaryjoin=lambda: sa.and_( + NotificationPreferences.account_id == Account.id, + NotificationPreferences.notification_type == '', + ), + uselist=False, + viewonly=True, + ) + + @cached_property + def main_notification_preferences(self) -> NotificationPreferences: + """Return user's main notification preferences, toggling transports on/off.""" + if not self._main_notification_preferences: + main = NotificationPreferences( + notification_type='', + account=self, + by_email=True, + by_sms=True, + by_webpush=False, + by_telegram=False, + by_whatsapp=False, + ) + db.session.add(main) + return main + return self._main_notification_preferences + + # project_membership.py + projects_as_crew_memberships: DynamicMapped[ProjectMembership] = relationship( + lazy='dynamic', + foreign_keys=lambda: ProjectMembership.member_id, + viewonly=True, + ) + + # This is used to determine if it is safe to purge the subject's database record + projects_as_crew_noninvite_memberships: DynamicMapped[ + ProjectMembership + ] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectMembership.member_id == Account.id, + ~ProjectMembership.is_invite, + ), + viewonly=True, + ) + projects_as_crew_active_memberships: DynamicMapped[ + ProjectMembership + ] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectMembership.member_id == Account.id, + ProjectMembership.is_active, + ), + viewonly=True, + ) + + projects_as_crew = DynamicAssociationProxy['Project']( + 'projects_as_crew_active_memberships', 'project' + ) + + projects_as_editor_active_memberships: DynamicMapped[ + ProjectMembership + ] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectMembership.member_id == Account.id, + ProjectMembership.is_active, + ProjectMembership.is_editor.is_(True), + ), + viewonly=True, + ) + + projects_as_editor = DynamicAssociationProxy['Project']( + 'projects_as_editor_active_memberships', 'project' + ) + + # project.py + projects: DynamicMapped[Project] = relationship( + lazy='dynamic', + foreign_keys=lambda: Project.account_id, + back_populates='account', + ) + project_redirects: DynamicMapped[ProjectRedirect] = relationship( + lazy='dynamic', back_populates='account' + ) + + listed_projects: DynamicMapped[Project] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + Account.id == Project.account_id, + Project.state.PUBLISHED, + ), + viewonly=True, + ) + draft_projects: DynamicMapped[Project] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + Account.id == Project.account_id, + sa.or_(Project.state.DRAFT, Project.cfp_state.DRAFT), + ), + viewonly=True, + ) + projects_by_name: Mapped[dict[str, Project]] = with_roles( + relationship( + foreign_keys=lambda: Project.account_id, + collection_class=sa_orm.attribute_keyed_dict('name'), + viewonly=True, + ), + read={'all'}, + ) + + # proposal_membership.py + all_proposal_memberships: DynamicMapped[ProposalMembership] = relationship( + lazy='dynamic', + foreign_keys=lambda: ProposalMembership.member_id, + viewonly=True, + ) + + noninvite_proposal_memberships: DynamicMapped[ProposalMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProposalMembership.member_id == Account.id, + ~ProposalMembership.is_invite, + ), + viewonly=True, + ) + + proposal_memberships: DynamicMapped[ProposalMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProposalMembership.member_id == Account.id, + ProposalMembership.is_active, + ), + viewonly=True, + ) + + # This is a User property of the proposals the user account is a collaborator in + proposals = DynamicAssociationProxy['Proposal']('proposal_memberships', 'proposal') + + @property + def public_proposal_memberships(self) -> Query[ProposalMembership]: + """Query for all proposal memberships to proposals that are public.""" + 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) + ) + ) + + public_proposals = DynamicAssociationProxy['Proposal']( + 'public_proposal_memberships', 'proposal' + ) + + # proposal.py + created_proposals: DynamicMapped[Proposal] = relationship( + lazy='dynamic', back_populates='created_by' + ) + + # rsvp.py + rsvps: DynamicMapped[Rsvp] = relationship( + 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' + ) + saved_sessions: DynamicMapped[SavedSession] = relationship( + lazy='dynamic', passive_deletes=True, back_populates='account' + ) + + def saved_sessions_in(self, project: Project) -> Query[SavedSession]: + return self.saved_sessions.join(Session).filter(Session.project == project) + + # site_membership.py + # Singular, as only one can be active + active_site_membership: Mapped[SiteMembership] = relationship( + lazy='select', + primaryjoin=lambda: sa.and_( + SiteMembership.member_id == Account.id, SiteMembership.is_active + ), + viewonly=True, + uselist=False, + ) + + @cached_property + def is_comment_moderator(self) -> bool: + """Test if this user is a comment moderator.""" + return ( + self.active_site_membership is not None + and self.active_site_membership.is_comment_moderator + ) + + @cached_property + def is_user_moderator(self) -> bool: + """Test if this user is an account moderator.""" + return ( + self.active_site_membership is not None + and self.active_site_membership.is_user_moderator + ) + + @cached_property + def is_site_editor(self) -> bool: + """Test if this user is a site editor.""" + return ( + self.active_site_membership is not None + and self.active_site_membership.is_site_editor + ) + + @cached_property + def is_sysadmin(self) -> bool: + """Test if this user is a sysadmin.""" + return ( + self.active_site_membership is not None + and self.active_site_membership.is_sysadmin + ) + + # site_admin means user has one or more of above roles + @cached_property + def is_site_admin(self) -> bool: + """Test if this user has any site-level admin rights.""" + return self.active_site_membership is not None + + # sponsor_membership.py + noninvite_project_sponsor_memberships: DynamicMapped[ + ProjectSponsorMembership + ] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectSponsorMembership.member_id == Account.id, + ~ProjectSponsorMembership.is_invite, + ), + order_by=lambda: ProjectSponsorMembership.granted_at.desc(), + viewonly=True, + ) + + project_sponsor_memberships: DynamicMapped[ProjectSponsorMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectSponsorMembership.member_id == Account.id, + ProjectSponsorMembership.is_active, + ), + order_by=lambda: ProjectSponsorMembership.granted_at.desc(), + viewonly=True, + ) + + project_sponsor_membership_invites: DynamicMapped[ + ProjectSponsorMembership + ] = with_roles( + relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectSponsorMembership.member_id == Account.id, + ProjectSponsorMembership.is_invite, + ProjectSponsorMembership.revoked_at.is_(None), + ), + order_by=lambda: ProjectSponsorMembership.granted_at.desc(), + viewonly=True, + ), + read={'admin'}, + ) + + noninvite_proposal_sponsor_memberships: DynamicMapped[ + ProposalSponsorMembership + ] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProposalSponsorMembership.member_id == Account.id, + ~ProposalSponsorMembership.is_invite, + ), + order_by=lambda: ProposalSponsorMembership.granted_at.desc(), + viewonly=True, + ) + + proposal_sponsor_memberships: DynamicMapped[ + ProposalSponsorMembership + ] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProposalSponsorMembership.member_id == Account.id, + ProposalSponsorMembership.is_active, + ), + order_by=lambda: ProposalSponsorMembership.granted_at.desc(), + viewonly=True, + ) + + proposal_sponsor_membership_invites: DynamicMapped[ + ProposalSponsorMembership + ] = with_roles( + relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProposalSponsorMembership.member_id == Account.id, + ProposalSponsorMembership.is_invite, + ProposalSponsorMembership.revoked_at.is_(None), + ), + order_by=lambda: ProposalSponsorMembership.granted_at.desc(), + viewonly=True, + ), + read={'admin'}, + ) + + sponsored_projects = DynamicAssociationProxy['Project']( + 'project_sponsor_memberships', 'project' + ) + + sponsored_proposals = DynamicAssociationProxy['Project']( + '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', + foreign_keys=lambda: Update.created_by_id, + back_populates='created_by', + ) + published_updates: DynamicMapped[Update] = relationship( + lazy='dynamic', + foreign_keys=lambda: Update.published_by_id, + back_populates='published_by', + ) + deleted_updates: DynamicMapped[Update] = relationship( + lazy='dynamic', + foreign_keys=lambda: Update.deleted_by_id, + back_populates='deleted_by', + ) + __table_args__ = ( sa.Index( 'ix_account_name_lower', @@ -388,9 +994,9 @@ class Account(UuidMixin, BaseMixin, Model): ) @classmethod - def _defercols(cls) -> list[sa.orm.interfaces.LoaderOption]: + def _defercols(cls) -> list[sa_orm.interfaces.LoaderOption]: """Return columns that are typically deferred when loading a user.""" - defer = sa.orm.defer + defer = sa_orm.defer return [ defer(cls.created_at), defer(cls.updated_at), @@ -405,8 +1011,10 @@ def type_filter(cls) -> sa.ColumnElement[bool]: """Return filter for the subclass's type.""" return cls.type_ == cls.__mapper_args__.get('polymorphic_identity') - primary_email: Mapped[AccountEmail | None] = relationship() - primary_phone: Mapped[AccountPhone | None] = relationship() + if TYPE_CHECKING: + # These are added via add_primary_relationship + primary_email: Mapped[AccountEmail | None] = relationship() + primary_phone: Mapped[AccountPhone | None] = relationship() def __repr__(self) -> str: if self.name: @@ -420,7 +1028,7 @@ def __str__(self) -> str: def __format__(self, format_spec: str) -> str: if not format_spec: return self.pickername - return self.pickername.__format__(format_spec) + return format(self.pickername, format_spec) @property def pickername(self) -> str: @@ -432,7 +1040,7 @@ def pickername(self) -> str: with_roles(pickername, read={'all'}) def roles_for( - self, actor: Account | None = None, anchors: Iterable = () + self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: """Identify roles for the given actor.""" roles = super().roles_for(actor, anchors) @@ -472,7 +1080,9 @@ def _set_password(self, password: str | None): # Also see :meth:`password_is` for transparent upgrade self.pw_set_at = sa.func.utcnow() # Expire passwords after one year. TODO: make this configurable - self.pw_expires_at = self.pw_set_at + timedelta(days=365) + self.pw_expires_at = self.pw_set_at + sa.cast( # type: ignore[assignment] + '1 year', sa.Interval + ) #: Write-only property (passwords cannot be read back in plain text) password = property(fset=_set_password, doc=_set_password.__doc__) @@ -652,13 +1262,13 @@ def has_any_memberships(self) -> bool: @with_roles(call={'owner'}) def has_transport_email(self) -> bool: """User has an email transport address.""" - return self.state.ACTIVE and bool(self.email) + return bool(self.state.ACTIVE) and bool(self.email) @with_roles(call={'owner'}) def has_transport_sms(self) -> bool: """User has an SMS transport address.""" return ( - self.state.ACTIVE + bool(self.state.ACTIVE) and self.phone != '' and self.phone.phone_number.has_sms is not False ) @@ -677,7 +1287,7 @@ def has_transport_telegram(self) -> bool: # TODO # pragma: no cover def has_transport_whatsapp(self) -> bool: """User has a WhatsApp transport address.""" return ( - self.state.ACTIVE + bool(self.state.ACTIVE) and self.phone != '' and self.phone.phone_number.has_wa is not False ) @@ -720,15 +1330,7 @@ def transport_for_telegram( def transport_for_whatsapp(self, context: Model | None = None): """Return user's preferred WhatsApp transport address within a context.""" # TODO: Per-account/project customization is a future option - if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.allow_wa: - return self.phone - return None - - @with_roles(call={'owner'}) - def transport_for_signal(self, context: Model | None = None): - """Return user's preferred Signal transport address within a context.""" - # TODO: Per-account/project customization is a future option - if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.allow_sm: + if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.has_wa: return self.phone return None @@ -850,8 +1452,8 @@ def do_delete(self): self.member_teams.clear() # 4. Revoke auth tokens - self.revoke_all_auth_tokens() # Defined in auth_client.py - self.revoke_all_auth_client_permissions() # Same place + AuthToken.revoke_all_for(self) + AuthClientPermissions.revoke_all_for(self) # 5. Revoke all active login sessions for login_session in self.active_login_sessions: @@ -917,7 +1519,7 @@ def name_is(cls, name: str) -> ColumnElement: @classmethod def name_in(cls, names: Iterable[str]) -> ColumnElement: - """Generate query flter to check if name is among candidates.""" + """Generate query filter to check if name is among candidates.""" return sa.func.lower(cls.name).in_( [name.lower().replace('-', '_') for name in names] ) @@ -1039,7 +1641,7 @@ def all_public(cls) -> Query: return query @classmethod - def autocomplete(cls, prefix: str) -> list[Account]: + def autocomplete(cls, prefix: str) -> list[Self]: """ Return accounts whose names begin with the prefix, for autocomplete UI. @@ -1125,7 +1727,7 @@ def autocomplete(cls, prefix: str) -> list[Account]: # No '@' in the query, so do a regular autocomplete try: users = base_users.all() - except sa.exc.ProgrammingError: + except sa_exc.ProgrammingError: # This can happen because the tsquery from prefix turned out to be ':*' users = [] return users @@ -1156,7 +1758,7 @@ def validate_name_candidate(cls, name: str) -> str | None: # will add a filter condition on subclasses to restrict the query to that type. existing = ( Account.query.filter(sa.func.lower(Account.name) == sa.func.lower(name)) - .options(sa.orm.load_only(cls.id, cls.uuid, cls.type_)) + .options(sa_orm.load_only(cls.id, cls.uuid, cls.type_)) .one_or_none() ) if existing is not None: @@ -1179,7 +1781,7 @@ def is_available_name(cls, name: str) -> bool: """Test if the candidate name is available for use as an Account name.""" return cls.validate_name_candidate(name) is None - @sa.orm.validates('name') + @sa_orm.validates('name') def _validate_name(self, key: str, value: str | None) -> str | None: """Validate the value of Account.name.""" if value is None: @@ -1200,7 +1802,7 @@ def _validate_name(self, key: str, value: str | None) -> str | None: # to the db and catch IntegrityError. return value - @sa.orm.validates('logo_url', 'banner_image_url') + @sa_orm.validates('logo_url', 'banner_image_url') def _validate_nullable(self, key: str, value: str | None): """Convert blank values into None.""" return value if value else None @@ -1215,12 +1817,72 @@ def organization_links(self) -> list: """Return list of organizations affiliated with this user (deprecated).""" return [] + # Project methods + + def draft_projects_for(self, user: Account | None) -> list[Project]: + if user is not None: + return [ + membership.project + for membership in user.projects_as_crew_active_memberships.join( + Project + ).filter( + # Project is attached to this account + Project.account_id == self.id, + # Project is in draft state OR has a draft call for proposals + sa.or_(Project.state.DRAFT, Project.cfp_state.DRAFT), + ) + ] + return [] + + def unscheduled_projects_for(self, user: Account | None) -> list[Project]: + if user is not None: + return [ + membership.project + for membership in user.projects_as_crew_active_memberships.join( + Project + ).filter( + # Project is attached to this account + Project.account_id == self.id, + # Project is in draft state OR has a draft call for proposals + sa.or_(Project.state.PUBLISHED_WITHOUT_SESSIONS), + ) + ] + return [] + + @with_roles(read={'all'}, datasets={'primary', 'without_parent', 'related'}) + @cached_property + def published_project_count(self) -> int: + return ( + self.listed_projects.filter(Project.state.PUBLISHED).order_by(None).count() + ) + + @with_roles(grants_via={None: {'participant': 'member'}}) + @cached_property + def membership_project(self) -> Project | None: + """Return a project that has memberships flag enabled (temporary).""" + return self.projects.filter( + Project.boxoffice_data.op('@>')({'has_membership': True}) + ).first() + # 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_') # noqa: A003 +Account.__active_membership_attrs__.add('active_organization_admin_memberships') +Account.__noninvite_membership_attrs__.add('noninvite_organization_admin_memberships') +Account.__active_membership_attrs__.add('projects_as_crew_active_memberships') +Account.__noninvite_membership_attrs__.add('projects_as_crew_noninvite_memberships') +Account.__active_membership_attrs__.add('proposal_memberships') +Account.__noninvite_membership_attrs__.add('noninvite_proposal_memberships') +Account.__active_membership_attrs__.update( + {'project_sponsor_memberships', 'proposal_sponsor_memberships'} +) +Account.__noninvite_membership_attrs__.update( + {'noninvite_project_sponsor_memberships', 'noninvite_proposal_sponsor_memberships'} +) + auto_init_default(Account._state) # pylint: disable=protected-access auto_init_default(Account._profile_state) # pylint: disable=protected-access add_search_trigger(Account, 'search_vector') @@ -1234,19 +1896,17 @@ class AccountOldId(UuidMixin, BaseMixin[UUID], Model): #: Old account, if still present old_account: Mapped[Account] = relationship( - Account, - primaryjoin='foreign(AccountOldId.id) == remote(Account.uuid)', - backref=backref('oldid', uselist=False), + foreign_keys=lambda: AccountOldId.id, + primaryjoin=lambda: AccountOldId.id == Account.uuid, + back_populates='oldid', ) #: User id of new user - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) #: New account account: Mapped[Account] = relationship( - Account, - foreign_keys=[account_id], - backref=backref('oldids', cascade='all'), + foreign_keys=[account_id], back_populates='oldids' ) def __repr__(self) -> str: @@ -1275,8 +1935,9 @@ def __init__(self, **kwargs) -> None: Account.userid = Account.uuid_b64 +# TODO: Make an Actor Protocol as the base for both -- maybe placing that in Coaster class DuckTypeAccount(RoleMixin): - """User singleton constructor. Ducktypes a regular user object.""" + """User singleton constructor. Duck types a regular user object.""" id: None = None # noqa: A003 created_at: None = None @@ -1337,7 +1998,7 @@ def __str__(self) -> str: def __format__(self, format_spec: str) -> str: if not format_spec: return self.pickername - return self.pickername.__format__(format_spec) + return format(self.pickername, format_spec) def url_for(self, *args, **kwargs) -> Literal['']: """Return blank URL for anything to do with this user.""" @@ -1351,31 +2012,6 @@ def url_for(self, *args, **kwargs) -> Literal['']: # --- Organizations and teams ------------------------------------------------- -team_membership = sa.Table( - 'team_membership', - Model.metadata, - sa.Column( - 'account_id', - sa.Integer, - sa.ForeignKey('account.id'), - nullable=False, - primary_key=True, - ), - sa.Column( - 'team_id', - sa.Integer, - sa.ForeignKey('team.id'), - nullable=False, - primary_key=True, - ), - sa.Column( - 'created_at', - sa.TIMESTAMP(timezone=True), - nullable=False, - default=sa.func.utcnow(), - ), -) - class Organization(Account): """An organization of one or more users with distinct roles.""" @@ -1399,7 +2035,7 @@ def people(self) -> Query[Account]: Account.query.join(team_membership) .join(Team) .filter(Team.account == self, Team.is_public.is_(True)) - .options(sa.orm.joinedload(Account.member_teams)) + .options(sa_orm.joinedload(Account.member_teams)) .order_by(sa.func.lower(Account.title)) ) @@ -1417,32 +2053,33 @@ class Team(UuidMixin, BaseMixin, Model): __tablename__ = 'team' __title_length__ = 250 #: Displayed name - title: Mapped[str] = sa.orm.mapped_column( + title: Mapped[str] = sa_orm.mapped_column( sa.Unicode(__title_length__), nullable=False ) #: Organization - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False, index=True ) account: Mapped[Account] = with_roles( - relationship( - Account, - foreign_keys=[account_id], - backref=backref('teams', order_by=sa.func.lower(title), cascade='all'), - ), + relationship(foreign_keys=[account_id], back_populates='teams'), grants_via={None: {'owner': 'owner', 'admin': 'admin'}}, ) users: DynamicMapped[Account] = with_roles( relationship( - Account, secondary=team_membership, lazy='dynamic', backref='member_teams' + secondary=team_membership, lazy='dynamic', back_populates='member_teams' ), grants={'member'}, ) - is_public: Mapped[bool] = sa.orm.mapped_column( + is_public: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) + # --- Backrefs + client_permissions: Mapped[list[AuthClientTeamPermissions]] = relationship( + back_populates='team' + ) + def __repr__(self) -> str: """Represent :class:`Team` as a string.""" return f'' @@ -1466,7 +2103,7 @@ def migrate_account( # `migrate_account` methods as team_membership is an unmapped table. new_account.member_teams.append(team) old_account.member_teams.remove(team) - return [cls.__table__.name, team_membership.name] + return [cast(sa.Table, cls.__table__).name, team_membership.name] @classmethod def get(cls, buid: str, with_parent: bool = False) -> Team | None: @@ -1476,7 +2113,7 @@ def get(cls, buid: str, with_parent: bool = False) -> Team | None: :param str buid: Buid of the team """ if with_parent: - query = cls.query.options(sa.orm.joinedload(cls.account)) + query = cls.query.options(sa_orm.joinedload(cls.account)) else: query = cls.query return query.filter_by(buid=buid).one_or_none() @@ -1489,7 +2126,6 @@ class AccountEmail(EmailAddressMixin, BaseMixin, Model): """An email address linked to an account.""" __tablename__ = 'account_email' - __email_optional__ = False __email_unique__ = True __email_is_exclusive__ = True __email_for__ = 'account' @@ -1497,15 +2133,13 @@ class AccountEmail(EmailAddressMixin, BaseMixin, Model): # Tell mypy that these are not optional email_address: Mapped[EmailAddress] # type: ignore[assignment] - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) - account: Mapped[Account] = relationship( - Account, backref=backref('emails', cascade='all') - ) - user: Mapped[Account] = sa.orm.synonym('account') + account: Mapped[Account] = relationship(back_populates='emails') + user: Mapped[Account] = sa_orm.synonym('account') - private: Mapped[bool] = sa.orm.mapped_column( + private: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) @@ -1515,7 +2149,7 @@ class AccountEmail(EmailAddressMixin, BaseMixin, Model): 'related': {'email', 'private', 'type'}, } - def __init__(self, account: Account, **kwargs) -> None: + def __init__(self, *, account: Account, **kwargs) -> None: email = kwargs.pop('email', None) if email: kwargs['email_address'] = EmailAddress.add_for(account, email) @@ -1663,14 +2297,13 @@ 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 [cast(sa.Table, cls.__table__).name, user_email_primary_table.name] class AccountEmailClaim(EmailAddressMixin, BaseMixin, Model): """Claimed but unverified email address for a user.""" __tablename__ = 'account_email_claim' - __email_optional__ = False __email_unique__ = False __email_for__ = 'account' __email_is_exclusive__ = False @@ -1678,18 +2311,16 @@ class AccountEmailClaim(EmailAddressMixin, BaseMixin, Model): # Tell mypy that these are not optional email_address: Mapped[EmailAddress] # type: ignore[assignment] - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) - account: Mapped[Account] = relationship( - Account, backref=backref('emailclaims', cascade='all') - ) - user: Mapped[Account] = sa.orm.synonym('account') - verification_code: Mapped[str] = sa.orm.mapped_column( + account: Mapped[Account] = relationship(back_populates='emailclaims') + user: Mapped[Account] = sa_orm.synonym('account') + verification_code: Mapped[str] = sa_orm.mapped_column( sa.String(44), nullable=False, default=newsecret ) - private: Mapped[bool] = sa.orm.mapped_column( + private: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) @@ -1701,10 +2332,8 @@ class AccountEmailClaim(EmailAddressMixin, BaseMixin, Model): 'related': {'email', 'private', 'type'}, } - def __init__(self, account: Account, **kwargs) -> None: - email = kwargs.pop('email', None) - if email: - kwargs['email_address'] = EmailAddress.add_for(account, email) + def __init__(self, *, account: Account, email: str, **kwargs) -> None: + kwargs['email_address'] = EmailAddress.add_for(account, email) super().__init__(account=account, **kwargs) self.blake2b = hashlib.blake2b( self.email.lower().encode(), digest_size=16 @@ -1845,7 +2474,7 @@ def get_by( ) @classmethod - def all(cls, email: str) -> Query[AccountEmailClaim]: # noqa: A003 + def all(cls, email: str) -> Query[Self]: # noqa: A003 """ Return all instances with the matching email address. @@ -1864,20 +2493,17 @@ class AccountPhone(PhoneNumberMixin, BaseMixin, Model): """A phone number linked to an account.""" __tablename__ = 'account_phone' - __phone_optional__ = False __phone_unique__ = True __phone_is_exclusive__ = True __phone_for__ = 'account' - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) - account: Mapped[Account] = relationship( - Account, backref=backref('phones', cascade='all') - ) - user: Mapped[Account] = sa.orm.synonym('account') + account: Mapped[Account] = relationship(back_populates='phones') + user: Mapped[Account] = sa_orm.synonym('account') - private: Mapped[bool] = sa.orm.mapped_column( + private: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) @@ -1887,7 +2513,7 @@ class AccountPhone(PhoneNumberMixin, BaseMixin, Model): 'related': {'phone', 'private', 'type'}, } - def __init__(self, account, **kwargs) -> None: + def __init__(self, *, account: Account, **kwargs) -> None: phone = kwargs.pop('phone', None) if phone: kwargs['phone_number'] = PhoneNumber.add_for(account, phone) @@ -1902,8 +2528,8 @@ def __str__(self) -> str: return self.phone or '' @cached_property - def parsed(self) -> phonenumbers.PhoneNumber: - """Return parsed phone number using libphonenumbers.""" + def parsed(self) -> phonenumbers.PhoneNumber | None: + """Return parsed phone number using libphonenumber.""" return self.phone_number.parsed @cached_property @@ -2048,7 +2674,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 [cast(sa.Table, cls.__table__).name, user_phone_primary_table.name] class AccountExternalId(BaseMixin, Model): @@ -2057,58 +2683,56 @@ class AccountExternalId(BaseMixin, Model): __tablename__ = 'account_externalid' __at_username_services__: ClassVar[list[str]] = [] #: Foreign key to user table - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) #: User that this connected account belongs to - account: Mapped[Account] = relationship( - Account, backref=backref('externalids', cascade='all') - ) - user: Mapped[Account] = sa.orm.synonym('account') + 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.UnicodeText, nullable=False) #: Unique user id as per external service, used for identifying related accounts # FIXME: change to sa.Unicode - userid: Mapped[str] = sa.orm.mapped_column( + userid: Mapped[str] = sa_orm.mapped_column( sa.UnicodeText, nullable=False ) # Unique id (or obsolete OpenID) #: Optional public-facing username on the external service # FIXME: change to sa.Unicode - username: Mapped[str | None] = sa.orm.mapped_column( + username: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True ) # LinkedIn once used full URLs #: OAuth or OAuth2 access token # FIXME: change to sa.Unicode - oauth_token: Mapped[str | None] = sa.orm.mapped_column( + oauth_token: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True ) #: Optional token secret (not used in OAuth2, used by Twitter with OAuth1a) # FIXME: change to sa.Unicode - oauth_token_secret: Mapped[str | None] = sa.orm.mapped_column( + oauth_token_secret: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True ) #: OAuth token type (typically 'bearer') # FIXME: change to sa.Unicode - oauth_token_type: Mapped[str | None] = sa.orm.mapped_column( + oauth_token_type: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True ) #: OAuth2 refresh token # FIXME: change to sa.Unicode - oauth_refresh_token: Mapped[str | None] = sa.orm.mapped_column( + oauth_refresh_token: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True ) #: OAuth2 token expiry in seconds, as sent by service provider - oauth_expires_in: Mapped[int | None] = sa.orm.mapped_column( + oauth_expires_in: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, nullable=True ) #: OAuth2 token expiry timestamp, estimate from created_at + oauth_expires_in - oauth_expires_at: Mapped[datetime | None] = sa.orm.mapped_column( + oauth_expires_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True ) #: Timestamp of when this connected account was last (re-)authorised by the user - last_used_at: Mapped[datetime] = sa.orm.mapped_column( + last_used_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), default=sa.func.utcnow(), nullable=False ) @@ -2180,6 +2804,27 @@ def get( Anchor = AccountEmail | AccountEmailClaim | AccountPhone | EmailAddress | PhoneNumber # Tail imports -# pylint: disable=wrong-import-position -from .membership_mixin import ImmutableMembershipMixin # isort: skip -from .account_membership import AccountMembership # isort:skip +from .account_membership import AccountMembership +from .auth_client import AuthClient, AuthClientPermissions, AuthToken +from .login_session import LOGIN_SESSION_VALIDITY_PERIOD, LoginSession +from .mailer import Mailer +from .membership_mixin import ImmutableMembershipMixin +from .notification import NotificationPreferences, NotificationRecipient +from .project import Project, ProjectRedirect +from .project_membership import ProjectMembership +from .proposal import Proposal +from .proposal_membership import ProposalMembership +from .rsvp import Rsvp +from .saved import SavedProject, SavedSession +from .session import Session +from .site_membership import SiteMembership +from .sponsor_membership import ProjectSponsorMembership, ProposalSponsorMembership +from .sync_ticket import TicketParticipant +from .update import Update + +if TYPE_CHECKING: + from .auth_client import AuthClientTeamPermissions + from .comment import Comment, Commentset # noqa: F401 + 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 23faa8be8..ec16f669b 100644 --- a/funnel/models/account_membership.py +++ b/funnel/models/account_membership.py @@ -4,11 +4,10 @@ from werkzeug.utils import cached_property -from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles +from coaster.sqlalchemy import immutable, with_roles -from . import DynamicMapped, Mapped, Model, backref, relationship, sa +from . import Mapped, Model, relationship, sa, sa_orm from .account import Account -from .helpers import reopen from .membership_mixin import ImmutableUserMembershipMixin __all__ = ['AccountMembership'] @@ -78,28 +77,22 @@ class AccountMembership(ImmutableUserMembershipMixin, Model): } #: Organization that this membership is being granted on - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('account.id', ondelete='CASCADE'), nullable=False, ) account: Mapped[Account] = with_roles( - relationship( - Account, - foreign_keys=[account_id], - backref=backref( - 'memberships', lazy='dynamic', cascade='all', passive_deletes=True - ), - ), + relationship(foreign_keys=[account_id], back_populates='memberships'), grants_via={None: {'admin': 'account_admin', 'owner': 'account_owner'}}, ) - parent_id: Mapped[int] = sa.orm.synonym('account_id') + parent_id: Mapped[int] = sa_orm.synonym('account_id') parent_id_column = 'account_id' - parent: Mapped[Account] = sa.orm.synonym('account') + parent: Mapped[Account] = sa_orm.synonym('account') # Organization roles: is_owner: Mapped[bool] = immutable( - sa.orm.mapped_column(sa.Boolean, nullable=False, default=False) + sa_orm.mapped_column(sa.Boolean, nullable=False, default=False) ) @cached_property @@ -109,128 +102,3 @@ def offered_roles(self) -> set[str]: if self.is_owner: roles.add('owner') return roles - - -# Add active membership relationships to Account -@reopen(Account) -class __Account: - active_admin_memberships: DynamicMapped[AccountMembership] = with_roles( - relationship( - AccountMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(AccountMembership.account_id) == Account.id, - AccountMembership.is_active, - ), - order_by=AccountMembership.granted_at.asc(), - viewonly=True, - ), - grants_via={'member': {'admin', 'owner'}}, - ) - - active_owner_memberships: DynamicMapped[AccountMembership] = relationship( - AccountMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(AccountMembership.account_id) == Account.id, - AccountMembership.is_active, - AccountMembership.is_owner.is_(True), - ), - viewonly=True, - ) - - active_invitations: DynamicMapped[AccountMembership] = relationship( - AccountMembership, - lazy='dynamic', - primaryjoin=sa.and_( - sa.orm.remote(AccountMembership.account_id) == Account.id, - AccountMembership.is_invite, - AccountMembership.revoked_at.is_(None), - ), - viewonly=True, - ) - - owner_users = with_roles( - DynamicAssociationProxy[Account]('active_owner_memberships', 'member'), - read={'all'}, - ) - admin_users = with_roles( - DynamicAssociationProxy[Account]('active_admin_memberships', 'member'), - read={'all'}, - ) - - # pylint: disable=invalid-unary-operand-type - organization_admin_memberships: DynamicMapped[AccountMembership] = relationship( - AccountMembership, - lazy='dynamic', - foreign_keys=[AccountMembership.member_id], # type: ignore[has-type] - viewonly=True, - ) - - noninvite_organization_admin_memberships: DynamicMapped[ - AccountMembership - ] = relationship( - AccountMembership, - lazy='dynamic', - foreign_keys=[AccountMembership.member_id], - primaryjoin=sa.and_( - sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] - == Account.id, - ~AccountMembership.is_invite, - ), - viewonly=True, - ) - - active_organization_admin_memberships: DynamicMapped[ - AccountMembership - ] = relationship( - AccountMembership, - lazy='dynamic', - foreign_keys=[AccountMembership.member_id], - primaryjoin=sa.and_( - sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] - == Account.id, - AccountMembership.is_active, - ), - viewonly=True, - ) - - active_organization_owner_memberships: DynamicMapped[ - AccountMembership - ] = relationship( - AccountMembership, - lazy='dynamic', - foreign_keys=[AccountMembership.member_id], - primaryjoin=sa.and_( - sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] - == Account.id, - AccountMembership.is_active, - AccountMembership.is_owner.is_(True), - ), - viewonly=True, - ) - - active_organization_invitations: DynamicMapped[AccountMembership] = relationship( - AccountMembership, - lazy='dynamic', - foreign_keys=[AccountMembership.member_id], - primaryjoin=sa.and_( - sa.orm.remote(AccountMembership.member_id) # type: ignore[has-type] - == Account.id, - AccountMembership.is_invite, - AccountMembership.revoked_at.is_(None), - ), - viewonly=True, - ) - - organizations_as_owner = DynamicAssociationProxy( - 'active_organization_owner_memberships', 'account' - ) - - organizations_as_admin = DynamicAssociationProxy( - 'active_organization_admin_memberships', 'account' - ) - - -Account.__active_membership_attrs__.add('active_organization_admin_memberships') -Account.__noninvite_membership_attrs__.add('noninvite_organization_admin_memberships') diff --git a/funnel/models/auth_client.py b/funnel/models/auth_client.py index 8c1dc8b40..1b07404da 100644 --- a/funnel/models/auth_client.py +++ b/funnel/models/auth_client.py @@ -3,10 +3,10 @@ from __future__ import annotations import urllib.parse -from collections.abc import Iterable, Sequence +from collections.abc import Collection, Sequence from datetime import datetime, timedelta from hashlib import blake2b, sha256 -from typing import cast, overload +from typing import Self, cast, overload from sqlalchemy.orm import attribute_keyed_dict, load_only from sqlalchemy.orm.query import Query as QueryBaseClass @@ -23,15 +23,14 @@ Model, Query, UuidMixin, - backref, db, declarative_mixin, declared_attr, relationship, sa, + sa_orm, ) from .account import Account, Team -from .helpers import reopen from .login_session import LoginSession, auth_client_login_session __all__ = [ @@ -54,19 +53,19 @@ class ScopeMixin: @classmethod def _scope(cls) -> Mapped[str]: """Database column for storing scopes as a space-separated string.""" - return sa.orm.mapped_column( + return sa_orm.mapped_column( 'scope', sa.UnicodeText, nullable=cls.__scope_null_allowed__ ) @property - def scope(self) -> Iterable[str]: + def scope(self) -> Collection[str]: """Represent scope column as a container of strings.""" if not self._scope: return () return tuple(sorted(self._scope.split())) @scope.setter - def scope(self, value: str | Iterable | None) -> None: + def scope(self, value: str | Collection | None) -> None: if value is None: if self.__scope_null_allowed__: self._scope = None @@ -78,7 +77,7 @@ def scope(self, value: str | Iterable | None) -> None: if not self._scope and self.__scope_null_allowed__: self._scope = None - def add_scope(self, additional: str | Iterable) -> None: + def add_scope(self, additional: str | Collection) -> None: """Add additional items to the scope.""" if isinstance(additional, str): additional = [additional] @@ -91,56 +90,52 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): __tablename__ = 'auth_client' __scope_null_allowed__ = True #: Account that owns this client - account_id: Mapped[int] = sa.orm.mapped_column( - sa.ForeignKey('account.id'), nullable=True + account_id: Mapped[int] = sa_orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False ) - account: Mapped[Account | None] = with_roles( - relationship( - Account, - foreign_keys=[account_id], - backref=backref('clients', cascade='all'), - ), + account: Mapped[Account] = with_roles( + relationship(back_populates='clients'), read={'all'}, write={'owner'}, grants_via={None: {'owner': 'owner', 'admin': 'admin'}}, ) #: Human-readable title title: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.Unicode(250), nullable=False), + sa_orm.mapped_column(sa.Unicode(250), nullable=False), read={'all'}, write={'owner'}, ) #: Long description description: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.UnicodeText, nullable=False, default=''), + sa_orm.mapped_column(sa.UnicodeText, nullable=False, default=''), read={'all'}, write={'owner'}, ) #: Confidential or public client? Public has no secret key confidential: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, nullable=False), read={'all'}, write={'owner'} + sa_orm.mapped_column(sa.Boolean, nullable=False), read={'all'}, write={'owner'} ) #: Website website: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.UnicodeText, nullable=False), # FIXME: Use UrlType + sa_orm.mapped_column(sa.UnicodeText, nullable=False), # FIXME: Use UrlType read={'all'}, write={'owner'}, ) #: Redirect URIs (one or more) - _redirect_uris: Mapped[str | None] = sa.orm.mapped_column( + _redirect_uris: Mapped[str | None] = sa_orm.mapped_column( 'redirect_uri', sa.UnicodeText, nullable=True, default='' ) #: Back-end notification URI (TODO: deprecated, needs better architecture) notification_uri: Mapped[str | None] = with_roles( # FIXME: Use UrlType - sa.orm.mapped_column(sa.UnicodeText, nullable=True, default=''), rw={'owner'} + sa_orm.mapped_column(sa.UnicodeText, nullable=True, default=''), rw={'owner'} ) #: Active flag - active: Mapped[bool] = sa.orm.mapped_column( + active: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=True ) #: Allow anyone to login to this app? allow_any_login: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, nullable=False, default=True), + sa_orm.mapped_column(sa.Boolean, nullable=False, default=True), read={'all'}, write={'owner'}, ) @@ -151,14 +146,30 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): #: However, resources in the scope column (via ScopeMixin) are granted for #: any arbitrary user without explicit user authorization. trusted: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, nullable=False, default=False), read={'all'} + sa_orm.mapped_column(sa.Boolean, nullable=False, default=False), read={'all'} ) + # --- Backrefs + login_sessions: DynamicMapped[LoginSession] = relationship( - LoginSession, lazy='dynamic', secondary=auth_client_login_session, - backref=backref('auth_clients', lazy='dynamic'), + back_populates='auth_clients', + ) + credentials: Mapped[dict[str, AuthClientCredential]] = relationship( + collection_class=attribute_keyed_dict('name'), back_populates='auth_client' + ) + authcodes: DynamicMapped[AuthCode] = relationship( + lazy='dynamic', back_populates='auth_client' + ) + authtokens: DynamicMapped[AuthToken] = relationship( + lazy='dynamic', back_populates='auth_client' + ) + account_permissions: Mapped[list[AuthClientPermissions]] = relationship( + back_populates='auth_client' + ) + team_permissions: Mapped[list[AuthClientTeamPermissions]] = relationship( + back_populates='auth_client' ) __roles__ = { @@ -178,12 +189,12 @@ def secret_is(self, candidate: str, name: str) -> bool: return credential.secret_is(candidate) @property - def redirect_uris(self) -> Iterable[str]: + def redirect_uris(self) -> Sequence[str]: """Return redirect URIs as a sequence.""" return tuple(self._redirect_uris.split()) if self._redirect_uris else () @redirect_uris.setter - def redirect_uris(self, value: Iterable) -> None: + def redirect_uris(self, value: Sequence[str]) -> None: """Set redirect URIs from a sequence, storing internally as lines of text.""" self._redirect_uris = '\r\n'.join(value) @@ -203,7 +214,7 @@ def host_matches(self, url: str) -> bool: if netloc: return netloc in ( urllib.parse.urlsplit(r).netloc - for r in (self.redirect_uris + (self.website,)) + for r in (tuple(self.redirect_uris) + (self.website,)) ) return False @@ -216,7 +227,7 @@ def authtoken_for( self, account: Account | None, login_session: LoginSession | None = None ) -> AuthToken | None: """ - Return the authtoken for this account and client. + Return the auth token for this account and client. Only works for confidential clients. """ @@ -252,7 +263,7 @@ def get(cls, buid: str) -> AuthClient | None: return cls.query.filter(cls.buid == buid, cls.active.is_(True)).one_or_none() @classmethod - def all_for(cls, account: Account | None) -> Query[AuthClient]: + def all_for(cls, account: Account | None) -> Query[Self]: """Return all clients, optionally all clients owned by the specified account.""" if account is None: return cls.query.order_by(cls.title) @@ -284,33 +295,26 @@ class AuthClientCredential(BaseMixin, Model): """ __tablename__ = 'auth_client_credential' - auth_client_id: Mapped[int] = sa.orm.mapped_column( + auth_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False ) auth_client: Mapped[AuthClient] = with_roles( - relationship( - AuthClient, - backref=backref( - 'credentials', - cascade='all, delete-orphan', - collection_class=attribute_keyed_dict('name'), - ), - ), + relationship(back_populates='credentials'), grants_via={None: {'owner'}}, ) #: OAuth client key - name: Mapped[str] = sa.orm.mapped_column( + name: Mapped[str] = sa_orm.mapped_column( sa.String(22), nullable=False, unique=True, default=make_buid ) #: User description for this credential - title: Mapped[str] = sa.orm.mapped_column( + title: Mapped[str] = sa_orm.mapped_column( sa.Unicode(250), nullable=False, default='' ) #: OAuth client secret, hashed - secret_hash: Mapped[str] = sa.orm.mapped_column(sa.Unicode, nullable=False) + secret_hash: Mapped[str] = sa_orm.mapped_column(sa.Unicode, nullable=False) #: When was this credential last used for an API call? - accessed_at: Mapped[datetime | None] = sa.orm.mapped_column( + accessed_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) @@ -371,27 +375,23 @@ class AuthCode(ScopeMixin, BaseMixin, Model): """Short-lived authorization tokens.""" __tablename__ = 'auth_code' - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) - account: Mapped[Account] = relationship(Account, foreign_keys=[account_id]) - auth_client_id: Mapped[int] = sa.orm.mapped_column( + account: Mapped[Account] = relationship(foreign_keys=[account_id]) + auth_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False ) - auth_client: Mapped[AuthClient] = relationship( - AuthClient, - foreign_keys=[auth_client_id], - backref=backref('authcodes', cascade='all'), - ) - login_session_id: Mapped[int | None] = sa.orm.mapped_column( + auth_client: Mapped[AuthClient] = relationship(back_populates='authcodes') + login_session_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('login_session.id'), nullable=True ) - login_session: Mapped[LoginSession | None] = relationship(LoginSession) - code: Mapped[str] = sa.orm.mapped_column( + login_session: Mapped[LoginSession | None] = relationship() + code: Mapped[str] = sa_orm.mapped_column( sa.String(44), default=newsecret, nullable=False ) - redirect_uri: Mapped[str] = sa.orm.mapped_column(sa.UnicodeText, nullable=False) - used: Mapped[bool] = sa.orm.mapped_column(sa.Boolean, default=False, nullable=False) + redirect_uri: Mapped[str] = sa_orm.mapped_column(sa.UnicodeText, nullable=False) + used: Mapped[bool] = sa_orm.mapped_column(sa.Boolean, default=False, nullable=False) def is_valid(self) -> bool: """Test if this auth code is still valid.""" @@ -400,7 +400,7 @@ def is_valid(self) -> bool: return not self.used and self.created_at >= utcnow() - timedelta(minutes=3) @classmethod - def all_for(cls, account: Account) -> Query[AuthCode]: + def all_for(cls, account: Account) -> Query[Self]: """Return all auth codes for the specified account.""" return cls.query.filter(cls.account == account) @@ -418,52 +418,46 @@ class AuthToken(ScopeMixin, BaseMixin, Model): __tablename__ = 'auth_token' # Account id is null for client-only tokens and public clients as the account is # identified via login_session.account there - account_id: Mapped[int | None] = sa.orm.mapped_column( + account_id: Mapped[int | None] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=True ) - account: Mapped[Account | None] = relationship( - Account, - backref=backref('authtokens', lazy='dynamic', cascade='all'), - ) + account: Mapped[Account | None] = relationship(back_populates='authtokens') #: The session in which this token was issued, null for confidential clients - login_session_id: Mapped[int | None] = sa.orm.mapped_column( + login_session_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('login_session.id'), nullable=True ) login_session: Mapped[LoginSession | None] = with_roles( - relationship(LoginSession, backref=backref('authtokens', lazy='dynamic')), + relationship(back_populates='authtokens'), read={'owner'}, ) - #: The client this authtoken is for - auth_client_id: Mapped[int] = sa.orm.mapped_column( + #: The client this auth token is for + auth_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True ) auth_client: Mapped[AuthClient] = with_roles( - relationship( - AuthClient, - backref=backref('authtokens', lazy='dynamic', cascade='all'), - ), + relationship(back_populates='authtokens'), read={'owner'}, ) #: The token - token: Mapped[str] = sa.orm.mapped_column( + token: Mapped[str] = sa_orm.mapped_column( sa.String(22), default=make_buid, nullable=False, unique=True ) #: The token's type, 'bearer', 'mac' or a URL - token_type: Mapped[str] = sa.orm.mapped_column( + token_type: Mapped[str] = sa_orm.mapped_column( sa.String(250), default='bearer', nullable=False ) #: Token secret for 'mac' type - secret: Mapped[str | None] = sa.orm.mapped_column(sa.String(44), nullable=True) + secret: Mapped[str | None] = sa_orm.mapped_column(sa.String(44), nullable=True) #: Secret's algorithm (for 'mac' type) - algorithm: Mapped[str | None] = sa.orm.mapped_column(sa.String(20), nullable=True) + algorithm: Mapped[str | None] = sa_orm.mapped_column(sa.String(20), nullable=True) #: Token's validity period in seconds, 0 = unlimited - validity: Mapped[int] = sa.orm.mapped_column(sa.Integer, nullable=False, default=0) + validity: Mapped[int] = sa_orm.mapped_column(sa.Integer, nullable=False, default=0) #: Refresh token, to obtain a new token - refresh_token: Mapped[str | None] = sa.orm.mapped_column( + refresh_token: Mapped[str | None] = sa_orm.mapped_column( sa.String(22), nullable=True, unique=True ) - # Only one authtoken per user and client. Add to scope as needed + # Only one auth token per user and client. Add to scope as needed __table_args__ = ( sa.UniqueConstraint('account_id', 'auth_client_id'), sa.UniqueConstraint('login_session_id', 'auth_client_id'), @@ -520,7 +514,7 @@ def refresh(self) -> None: self.token = make_buid() self.secret = newsecret() - @sa.orm.validates('algorithm') + @sa_orm.validates('algorithm') def _validate_algorithm(self, _key: str, value: str | None) -> str | None: """Set mac token algorithm to one of supported values.""" if value is None: @@ -539,6 +533,11 @@ def is_valid(self) -> bool: return False return True + @classmethod + def revoke_all_for(cls, account: Account) -> None: + """Revoke all auth tokens directly linked to the account.""" + cls.all_for(account).delete(synchronize_session=False) + @classmethod def migrate_account(cls, old_account: Account, new_account: Account) -> None: """Migrate one account's data to another when merging accounts.""" @@ -601,7 +600,7 @@ def get_for( ).one_or_none() @classmethod - def all(cls, accounts: Query | Sequence[Account]) -> list[AuthToken]: # noqa: A003 + def all(cls, accounts: Query | Collection[Account]) -> list[Self]: # noqa: A003 """Return all AuthToken for the specified accounts.""" query = cls.query.join(AuthClient) if isinstance(accounts, QueryBaseClass): @@ -627,7 +626,7 @@ def all(cls, accounts: Query | Sequence[Account]) -> list[AuthToken]: # noqa: A return [] @classmethod - def all_for(cls, account: Account) -> Query[AuthToken]: + def all_for(cls, account: Account) -> Query[Self]: """Get all AuthTokens for a specified account (direct only).""" return cls.query.filter(cls.account == account) @@ -640,28 +639,20 @@ class AuthClientPermissions(BaseMixin, Model): __tablename__ = 'auth_client_permissions' __tablename__ = 'auth_client_permissions' #: User account that has these permissions - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) - account: Mapped[Account] = relationship( - Account, - foreign_keys=[account_id], - backref=backref('client_permissions', cascade='all'), - ) + account: Mapped[Account] = relationship(back_populates='client_permissions') #: AuthClient app they are assigned on - auth_client_id: Mapped[int] = sa.orm.mapped_column( + auth_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True ) auth_client: Mapped[AuthClient] = with_roles( - relationship( - AuthClient, - foreign_keys=[auth_client_id], - backref=backref('account_permissions', cascade='all'), - ), + relationship(back_populates='account_permissions'), grants_via={None: {'owner'}}, ) #: The permissions as a string of tokens - access_permissions: Mapped[str] = sa.orm.mapped_column( + access_permissions: Mapped[str] = sa_orm.mapped_column( 'permissions', sa.UnicodeText, default='', nullable=False ) @@ -674,6 +665,11 @@ def pickername(self) -> str: """Return label string for identification of the subject account.""" return self.account.pickername + @classmethod + def revoke_all_for(cls, account: Account) -> None: + """Revoke all permissions on client apps assigned to account.""" + cls.all_for(account).delete(synchronize_session=False) + @classmethod def migrate_account(cls, old_account: Account, new_account: Account) -> None: """Migrate one account's data to another when merging accounts.""" @@ -702,12 +698,12 @@ def get( ).one_or_none() @classmethod - def all_for(cls, account: Account) -> Query[AuthClientPermissions]: + def all_for(cls, account: Account) -> Query[Self]: """Get all permissions assigned to account for various clients.""" return cls.query.filter(cls.account == account) @classmethod - def all_forclient(cls, auth_client: AuthClient) -> Query[AuthClientPermissions]: + def all_forclient(cls, auth_client: AuthClient) -> Query[Self]: """Get all permissions assigned on the specified auth client.""" return cls.query.filter(cls.auth_client == auth_client) @@ -719,28 +715,20 @@ class AuthClientTeamPermissions(BaseMixin, Model): __tablename__ = 'auth_client_team_permissions' #: Team which has these permissions - team_id: Mapped[int] = sa.orm.mapped_column( + team_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('team.id'), nullable=False ) - team: Mapped[Team] = relationship( - Team, - foreign_keys=[team_id], - backref=backref('client_permissions', cascade='all'), - ) + team: Mapped[Team] = relationship(back_populates='client_permissions') #: AuthClient app they are assigned on - auth_client_id: Mapped[int] = sa.orm.mapped_column( + auth_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True ) auth_client: Mapped[AuthClient] = with_roles( - relationship( - AuthClient, - foreign_keys=[auth_client_id], - backref=backref('team_permissions', cascade='all'), - ), + relationship(back_populates='team_permissions'), grants_via={None: {'owner'}}, ) #: The permissions as a string of tokens - access_permissions: Mapped[str] = sa.orm.mapped_column( + access_permissions: Mapped[str] = sa_orm.mapped_column( 'permissions', sa.UnicodeText, default='', nullable=False ) @@ -763,9 +751,7 @@ def get( ).one_or_none() @classmethod - def all_for( - cls, auth_client: AuthClient, account: Account - ) -> Query[AuthClientTeamPermissions]: + def all_for(cls, auth_client: AuthClient, account: Account) -> Query[Self]: """Get all permissions for the specified account via their teams.""" return cls.query.filter( cls.auth_client == auth_client, @@ -773,19 +759,6 @@ def all_for( ) @classmethod - def all_forclient(cls, auth_client: AuthClient) -> Query[AuthClientTeamPermissions]: + def all_forclient(cls, auth_client: AuthClient) -> Query[Self]: """Get all permissions assigned on the specified auth client.""" return cls.query.filter(cls.auth_client == auth_client) - - -@reopen(Account) -class __Account: - def revoke_all_auth_tokens(self) -> None: - """Revoke all auth tokens directly linked to the account.""" - AuthToken.all_for(cast(Account, self)).delete(synchronize_session=False) - - def revoke_all_auth_client_permissions(self) -> None: - """Revoke all permissions on client apps assigned to account.""" - AuthClientPermissions.all_for(cast(Account, self)).delete( - synchronize_session=False - ) diff --git a/funnel/models/comment.py b/funnel/models/comment.py index 87d11a475..389b80df2 100644 --- a/funnel/models/comment.py +++ b/funnel/models/comment.py @@ -19,11 +19,11 @@ Model, TSVectorType, UuidMixin, - backref, db, hybrid_property, relationship, sa, + sa_orm, ) from .account import ( Account, @@ -32,12 +32,7 @@ removed_account, unknown_account, ) -from .helpers import ( - MarkdownCompositeBasic, - MessageComposite, - add_search_trigger, - reopen, -) +from .helpers import MarkdownCompositeBasic, MessageComposite, add_search_trigger __all__ = ['Comment', 'Commentset'] @@ -89,7 +84,7 @@ class SET_TYPE: # noqa: N801 class Commentset(UuidMixin, BaseMixin, Model): __tablename__ = 'commentset' #: Commentset state code - _state: Mapped[int] = sa.orm.mapped_column( + _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.SmallInteger, StateManager.check_constraint('state', COMMENTSET_STATE), @@ -97,26 +92,71 @@ class Commentset(UuidMixin, BaseMixin, Model): default=COMMENTSET_STATE.OPEN, ) #: Commentset state manager - state = StateManager('_state', COMMENTSET_STATE, doc="Commentset state") + state = StateManager['Commentset']( + '_state', COMMENTSET_STATE, doc="Commentset state" + ) #: Type of parent object settype: Mapped[int | None] = with_roles( - sa.orm.mapped_column('type', sa.Integer, nullable=True), + sa_orm.mapped_column('type', sa.Integer, nullable=True), read={'all'}, datasets={'primary'}, ) #: Count of comments, stored to avoid count(*) queries count: Mapped[int] = with_roles( - sa.orm.mapped_column(sa.Integer, default=0, nullable=False), + sa_orm.mapped_column(sa.Integer, default=0, nullable=False), read={'all'}, datasets={'primary'}, ) #: Timestamp of last comment, for ordering. last_comment_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'}, datasets={'primary'}, ) + comments: DynamicMapped[Comment] = relationship( + lazy='dynamic', back_populates='commentset' + ) + toplevel_comments: DynamicMapped[Comment] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + Comment.commentset_id == Commentset.id, Comment.in_reply_to_id.is_(None) + ), + viewonly=True, + ) + active_memberships: DynamicMapped[CommentsetMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + CommentsetMembership.commentset_id == Commentset.id, + CommentsetMembership.is_active, + ), + viewonly=True, + ) + # Send notifications only to subscribers who haven't muted + active_memberships_unmuted: DynamicMapped[CommentsetMembership] = with_roles( + relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + CommentsetMembership.commentset_id == Commentset.id, + CommentsetMembership.is_active, + CommentsetMembership.is_muted.is_(False), + ), + viewonly=True, + ), + grants_via={'member': {'document_subscriber'}}, + ) + + project: Mapped[Project | None] = with_roles( + relationship(uselist=False, back_populates='commentset'), + grants_via={None: {'editor': 'document_subscriber'}}, + ) + proposal: Mapped[Proposal | None] = relationship( + uselist=False, back_populates='commentset' + ) + update: Mapped[Update | None] = relationship( + uselist=False, back_populates='commentset' + ) + __roles__ = { 'all': { 'read': {'project', 'proposal', 'update', 'urls'}, @@ -135,7 +175,7 @@ def __init__(self, **kwargs) -> None: @cached_property def parent(self) -> Project | Proposal | Update: - # FIXME: Move this to a CommentMixin that uses a registry, like EmailAddress + # TODO: Move this to a CommentMixin that uses a registry, like EmailAddress if self.project is not None: return self.project if self.proposal is not None: @@ -182,10 +222,7 @@ def post_comment( # 2. Make a CommentMixin (like EmailAddressMixin) and insert logic into the # parent, which can override methods and add custom restrictions comment = Comment( - posted_by=actor, - commentset=self, - message=message, - in_reply_to=in_reply_to, + posted_by=actor, commentset=self, message=message, in_reply_to=in_reply_to ) self.count = Commentset.count + 1 db.session.add(comment) @@ -201,62 +238,117 @@ def enable_comments(self): # Transitions for the other two states are pending on the TODO notes in post_comment + def update_last_seen_at(self, member: Account) -> None: + subscription = CommentsetMembership.query.filter_by( + commentset=self, member=member, is_active=True + ).one_or_none() + if subscription is not None: + subscription.update_last_seen_at() + + def add_subscriber(self, actor: Account, member: Account) -> bool: + """Return True is subscriber is added or unmuted, False if already exists.""" + changed = False + subscription = CommentsetMembership.query.filter_by( + commentset=self, member=member, is_active=True + ).one_or_none() + if subscription is None: + subscription = CommentsetMembership( + commentset=self, + member=member, + granted_by=actor, + ) + db.session.add(subscription) + changed = True + elif subscription.is_muted: + subscription = subscription.replace(actor=actor, is_muted=False) + changed = True + subscription.update_last_seen_at() + return changed + + def mute_subscriber(self, actor: Account, member: Account) -> bool: + """Return True if subscriber was muted, False if already muted or missing.""" + subscription = CommentsetMembership.query.filter_by( + commentset=self, member=member, is_active=True + ).one_or_none() + if subscription is not None and not subscription.is_muted: + subscription.replace(actor=actor, is_muted=True) + return True + return False + + def unmute_subscriber(self, actor: Account, member: Account) -> bool: + """Return True if subscriber was unmuted, False if not muted or missing.""" + subscription = CommentsetMembership.query.filter_by( + commentset=self, member=member, is_active=True + ).one_or_none() + if subscription is not None and subscription.is_muted: + subscription.replace(actor=actor, is_muted=False) + return True + return False + + def remove_subscriber(self, actor: Account, member: Account) -> bool: + """Return True is subscriber is removed, False if already removed.""" + subscription = CommentsetMembership.query.filter_by( + commentset=self, member=member, is_active=True + ).one_or_none() + if subscription is not None: + subscription.revoke(actor=actor) + return True + return False + class Comment(UuidMixin, BaseMixin, Model): __tablename__ = 'comment' - posted_by_id: Mapped[int | None] = sa.orm.mapped_column( + posted_by_id: Mapped[int | None] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=True ) _posted_by: Mapped[Account | None] = with_roles( - relationship( - Account, backref=backref('comments', lazy='dynamic', cascade='all') - ), + relationship(back_populates='comments'), grants={'author'}, ) - commentset_id: Mapped[int] = sa.orm.mapped_column( + commentset_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id'), nullable=False ) commentset: Mapped[Commentset] = with_roles( - relationship( - Commentset, - backref=backref('comments', lazy='dynamic', cascade='all'), - ), + relationship(back_populates='comments'), grants_via={None: {'document_subscriber'}}, ) - in_reply_to_id: Mapped[int | None] = sa.orm.mapped_column( + in_reply_to_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('comment.id'), nullable=True ) - replies: Mapped[list[Comment]] = relationship( - 'Comment', backref=backref('in_reply_to', remote_side='Comment.id') + in_reply_to: Mapped[Comment] = relationship( + back_populates='replies', remote_side='Comment.id' ) + replies: Mapped[list[Comment]] = relationship(back_populates='in_reply_to') _message, message_text, message_html = MarkdownCompositeBasic.create( 'message', nullable=False ) - _state: Mapped[int] = sa.orm.mapped_column( + _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.Integer, StateManager.check_constraint('state', COMMENT_STATE), default=COMMENT_STATE.SUBMITTED, nullable=False, ) - state = StateManager('_state', COMMENT_STATE, doc="Current state of the comment") + state = StateManager['Comment']( + '_state', COMMENT_STATE, doc="Current state of the comment" + ) edited_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'}, datasets={'primary', 'related', 'json'}, ) #: Revision number maintained by SQLAlchemy, starting at 1 revisionid: Mapped[int] = with_roles( - sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + sa_orm.mapped_column(sa.Integer, nullable=False), read={'all'} ) - search_vector: Mapped[str] = sa.orm.mapped_column( + search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( 'message_text', weights={'message_text': 'A'}, @@ -267,6 +359,10 @@ class Comment(UuidMixin, BaseMixin, Model): deferred=True, ) + moderator_reports: DynamicMapped[CommentModeratorReport] = relationship( + lazy='dynamic', back_populates='comment' + ) + __table_args__ = ( sa.Index('ix_comment_search_vector', 'search_vector', postgresql_using='gin'), ) @@ -324,7 +420,7 @@ def _posted_by_setter(self, value: Account | None) -> None: @posted_by.inplace.expression @classmethod - def _posted_by_expression(cls) -> sa.orm.InstrumentedAttribute[Account | None]: + def _posted_by_expression(cls) -> sa_orm.InstrumentedAttribute[Account | None]: """Return SQL Expression.""" return cls._posted_by @@ -346,7 +442,7 @@ def message(self) -> MessageComposite | MarkdownCompositeBasic: @message.inplace.setter def _message_setter(self, value: Any) -> None: """Edit the message of a comment.""" - self._message = value # type: ignore[assignment] + self._message = value @message.inplace.expression @classmethod @@ -366,14 +462,16 @@ def title(self) -> str: return _("{user} commented on {obj}").format( user=self.posted_by.pickername, obj=obj.title ) - return _("{account} commented").format(account=self.posted_by.pickername) + return _("{account} commented").format( # type: ignore[unreachable] + account=self.posted_by.pickername + ) with_roles(title, read={'all'}, datasets={'primary', 'related', 'json'}) @property def badges(self) -> set[str]: badges = set() - roles = set() + roles: set[str] | LazyRoleSet = set() if self.commentset.project is not None: roles = self.commentset.project.roles_for(self._posted_by) elif self.commentset.proposal is not None: @@ -416,6 +514,13 @@ def mark_spam(self) -> None: def mark_not_spam(self) -> None: """Mark this comment as not spam.""" + def was_reviewed_by(self, account: Account) -> bool: + return CommentModeratorReport.query.filter( + CommentModeratorReport.comment == self, + CommentModeratorReport.resolved_at.is_(None), + CommentModeratorReport.reported_by == account, + ).notempty() + def roles_for( self, actor: Account | None = None, anchors: Sequence = () ) -> LazyRoleSet: @@ -427,19 +532,10 @@ def roles_for( add_search_trigger(Comment, 'search_vector') -@reopen(Commentset) -class __Commentset: - toplevel_comments: DynamicMapped[Comment] = relationship( - Comment, - lazy='dynamic', - primaryjoin=sa.and_( - Comment.commentset_id == Commentset.id, Comment.in_reply_to_id.is_(None) - ), - viewonly=True, - ) - - # Tail imports for type checking +from .commentset_membership import CommentsetMembership +from .moderation import CommentModeratorReport + if TYPE_CHECKING: from .project import Project from .proposal import Proposal diff --git a/funnel/models/commentset_membership.py b/funnel/models/commentset_membership.py index 27cf4541e..409332f6d 100644 --- a/funnel/models/commentset_membership.py +++ b/funnel/models/commentset_membership.py @@ -3,15 +3,12 @@ from __future__ import annotations from datetime import datetime +from typing import TYPE_CHECKING, Self from werkzeug.utils import cached_property -from coaster.sqlalchemy import DynamicAssociationProxy, with_roles - -from . import DynamicMapped, Mapped, Model, Query, backref, db, relationship, sa +from . import Mapped, Model, Query, relationship, sa, sa_orm from .account import Account -from .comment import Comment, Commentset -from .helpers import reopen from .membership_mixin import ImmutableUserMembershipMixin from .project import Project from .proposal import Proposal @@ -40,42 +37,29 @@ class CommentsetMembership(ImmutableUserMembershipMixin, Model): } } - commentset_id: Mapped[int] = sa.orm.mapped_column( + commentset_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id', ondelete='CASCADE'), nullable=False, ) - commentset: Mapped[Commentset] = relationship( - Commentset, - backref=backref( - 'subscriber_memberships', - lazy='dynamic', - cascade='all', - passive_deletes=True, - ), - ) + commentset: Mapped[Commentset] = relationship() - parent_id: Mapped[int] = sa.orm.synonym('commentset_id') + parent_id: Mapped[int] = sa_orm.synonym('commentset_id') parent_id_column = 'commentset_id' - parent: Mapped[Commentset] = sa.orm.synonym('commentset') + parent: Mapped[Commentset] = sa_orm.synonym('commentset') #: Flag to indicate notifications are muted - is_muted: Mapped[bool] = sa.orm.mapped_column( + is_muted: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: When the user visited this commentset last - last_seen_at: Mapped[datetime] = sa.orm.mapped_column( + last_seen_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) - new_comment_count: Mapped[int] = sa.orm.column_property( - sa.select(sa.func.count(Comment.id)) - .where(Comment.commentset_id == commentset_id) # type: ignore[has-type] - .where(Comment.state.PUBLIC) # type: ignore[has-type] - .where(Comment.created_at > last_seen_at) - .correlate_except(Comment) - .scalar_subquery() - ) + if TYPE_CHECKING: + # The implementation is at bottom, following the tail imports + new_comment_count: Mapped[int] @cached_property def offered_roles(self) -> set[str]: @@ -91,7 +75,7 @@ def update_last_seen_at(self) -> None: self.last_seen_at = sa.func.utcnow() @classmethod - def for_user(cls, account: Account) -> Query[CommentsetMembership]: + def for_user(cls, account: Account) -> Query[Self]: """ Return a query representing all active commentset memberships for a user. @@ -118,103 +102,14 @@ def for_user(cls, account: Account) -> Query[CommentsetMembership]: ) -@reopen(Account) -class __Account: - active_commentset_memberships: DynamicMapped[CommentsetMembership] = relationship( - CommentsetMembership, - lazy='dynamic', - primaryjoin=sa.and_( - CommentsetMembership.member_id == Account.id, - CommentsetMembership.is_active, - ), - viewonly=True, - ) - - subscribed_commentsets = DynamicAssociationProxy( - 'active_commentset_memberships', 'commentset' - ) - - -@reopen(Commentset) -class __Commentset: - active_memberships: DynamicMapped[CommentsetMembership] = relationship( - CommentsetMembership, - lazy='dynamic', - primaryjoin=sa.and_( - CommentsetMembership.commentset_id == Commentset.id, - CommentsetMembership.is_active, - ), - viewonly=True, - ) - - # Send notifications only to subscribers who haven't muted - active_memberships_unmuted: DynamicMapped[CommentsetMembership] = with_roles( - relationship( - CommentsetMembership, - lazy='dynamic', - primaryjoin=sa.and_( - CommentsetMembership.commentset_id == Commentset.id, - CommentsetMembership.is_active, - CommentsetMembership.is_muted.is_(False), - ), - viewonly=True, - ), - grants_via={'member': {'document_subscriber'}}, - ) +# Tail imports +from .comment import Comment, Commentset - def update_last_seen_at(self, member: Account) -> None: - subscription = CommentsetMembership.query.filter_by( - commentset=self, member=member, is_active=True - ).one_or_none() - if subscription is not None: - subscription.update_last_seen_at() - - def add_subscriber(self, actor: Account, member: Account) -> bool: - """Return True is subscriber is added or unmuted, False if already exists.""" - changed = False - subscription = CommentsetMembership.query.filter_by( - commentset=self, member=member, is_active=True - ).one_or_none() - if subscription is None: - subscription = CommentsetMembership( - commentset=self, - member=member, - granted_by=actor, - ) - db.session.add(subscription) - changed = True - elif subscription.is_muted: - subscription = subscription.replace(actor=actor, is_muted=False) - changed = True - subscription.update_last_seen_at() - return changed - - def mute_subscriber(self, actor: Account, member: Account) -> bool: - """Return True if subscriber was muted, False if already muted or missing.""" - subscription = CommentsetMembership.query.filter_by( - commentset=self, member=member, is_active=True - ).one_or_none() - if not subscription.is_muted: - subscription.replace(actor=actor, is_muted=True) - return True - return False - - def unmute_subscriber(self, actor: Account, member: Account) -> bool: - """Return True if subscriber was unmuted, False if not muted or missing.""" - subscription = CommentsetMembership.query.filter_by( - commentset=self, member=member, is_active=True - ).one_or_none() - if subscription.is_muted: - subscription.replace(actor=actor, is_muted=False) - return True - return False - - def remove_subscriber(self, actor: Account, member: Account) -> bool: - """Return True is subscriber is removed, False if already removed.""" - subscription = CommentsetMembership.query.filter_by( - commentset=self, member=member, is_active=True - ).one_or_none() - if subscription is not None: - subscription.revoke(actor=actor) - return True - return False +CommentsetMembership.new_comment_count = sa_orm.column_property( + sa.select(sa.func.count(Comment.id)) + .where(Comment.commentset_id == CommentsetMembership.commentset_id) + .where(Comment.state.PUBLIC) + .where(Comment.created_at > CommentsetMembership.last_seen_at) + .correlate_except(Comment) + .scalar_subquery() +) diff --git a/funnel/models/contact_exchange.py b/funnel/models/contact_exchange.py index b8eab0694..d9398bc4a 100644 --- a/funnel/models/contact_exchange.py +++ b/funnel/models/contact_exchange.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import date as date_type, datetime from itertools import groupby +from typing import Self from uuid import UUID from pytz import timezone @@ -20,10 +21,10 @@ Query, RoleMixin, TimestampMixin, - backref, db, relationship, sa, + sa_orm, ) from .account import Account from .project import Project @@ -58,39 +59,30 @@ class ContactExchange(TimestampMixin, RoleMixin, Model): __tablename__ = 'contact_exchange' #: User who scanned this contact - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id', ondelete='CASCADE'), primary_key=True ) - account: Mapped[Account] = relationship( - Account, - backref=backref( - 'scanned_contacts', - lazy='dynamic', - order_by='ContactExchange.scanned_at.desc()', - passive_deletes=True, - ), - ) + account: Mapped[Account] = relationship(back_populates='scanned_contacts') #: Participant whose contact was scanned - ticket_participant_id: Mapped[int] = sa.orm.mapped_column( + ticket_participant_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_participant.id', ondelete='CASCADE'), primary_key=True, index=True, ) ticket_participant: Mapped[TicketParticipant] = relationship( - TicketParticipant, - backref=backref('scanned_contacts', passive_deletes=True), + back_populates='scanned_contacts' ) #: Datetime at which the scan happened - scanned_at: Mapped[datetime] = sa.orm.mapped_column( + scanned_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) #: Note recorded by the user (plain text) - description: Mapped[str] = sa.orm.mapped_column( + description: Mapped[str] = sa_orm.mapped_column( sa.UnicodeText, nullable=False, default='' ) #: Archived flag - archived: Mapped[bool] = sa.orm.mapped_column( + archived: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) @@ -156,10 +148,10 @@ def grouped_counts_for( query = ( db.session.query( - sa.column('project_id'), - sa.column('project_uuid'), - sa.column('project_title'), - sa.column('project_timezone'), + sa.column('project_id', sa.Integer()), + sa.column('project_uuid', sa.Uuid()), + sa.column('project_title', sa.String()), + sa.column('project_timezone', sa.String()), sa.cast( sa.func.date_trunc( 'day', @@ -257,7 +249,7 @@ def grouped_counts_for( @classmethod def contacts_for_project_and_date( cls, account: Account, project: Project, date: date_type, archived: bool = False - ) -> Query[ContactExchange]: + ) -> Query[Self]: """Return contacts for a given user, project and date.""" query = cls.query.join(TicketParticipant).filter( cls.account == account, @@ -284,7 +276,7 @@ def contacts_for_project_and_date( @classmethod def contacts_for_project( cls, account: Account, project: Project, archived: bool = False - ) -> Query[ContactExchange]: + ) -> Query[Self]: """Return contacts for a given user and project.""" query = cls.query.join(TicketParticipant).filter( cls.account == account, diff --git a/funnel/models/draft.py b/funnel/models/draft.py index d51f79b92..3080b685e 100644 --- a/funnel/models/draft.py +++ b/funnel/models/draft.py @@ -6,7 +6,7 @@ from werkzeug.datastructures import MultiDict -from . import Mapped, Model, NoIdMixin, sa, types +from . import Mapped, Model, NoIdMixin, sa_orm, types __all__ = ['Draft'] @@ -16,8 +16,8 @@ class Draft(NoIdMixin, Model): __tablename__ = 'draft' - table: Mapped[types.text] = sa.orm.mapped_column(primary_key=True) - table_row_id: Mapped[UUID] = 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 revision: Mapped[UUID | None] diff --git a/funnel/models/email_address.py b/funnel/models/email_address.py index 10b56c312..773b9f99c 100644 --- a/funnel/models/email_address.py +++ b/funnel/models/email_address.py @@ -4,8 +4,9 @@ import hashlib import unicodedata +import warnings from datetime import datetime -from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, cast, overload import base58 import idna @@ -15,7 +16,13 @@ from sqlalchemy.orm import NO_VALUE, Mapper from werkzeug.utils import cached_property -from coaster.sqlalchemy import StateManager, auto_init_default, immutable, with_roles +from coaster.sqlalchemy import ( + ModelWarning, + StateManager, + auto_init_default, + immutable, + with_roles, +) from coaster.utils import LabeledEnum, require_one_of from ..signals import emailaddress_refcount_dropping @@ -30,6 +37,7 @@ hybrid_property, relationship, sa, + sa_orm, ) __all__ = [ @@ -38,6 +46,7 @@ 'EmailAddressBlockedError', 'EmailAddressInUseError', 'EmailAddress', + 'OptionalEmailAddressMixin', 'EmailAddressMixin', ] @@ -176,7 +185,7 @@ class EmailAddress(BaseMixin, Model): 6. Upcoming: column-level encryption of the email column, securing SQL dumps. New email addresses must be added using the :meth:`add` or :meth:`add_for` - classmethods, depending on whether the email address is linked to an owner or not. + class methods, depending on whether the email address is linked to an owner or not. """ __tablename__ = 'email_address' @@ -185,15 +194,15 @@ class EmailAddress(BaseMixin, Model): #: Contains the name of the relationship in the :class:`EmailAddress` model __backrefs__: ClassVar[set[str]] = set() #: These backrefs claim exclusive use of the email address for their linked owner. - #: See :class:`EmailAddressMixin` for implementation detail + #: See :class:`OptionalEmailAddressMixin` for implementation detail __exclusive_backrefs__: ClassVar[set[str]] = set() #: The email address, centrepiece of this model. Case preserving. #: Validated by the :func:`_validate_email` event handler - email: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode, nullable=True) + email: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True) #: The domain of the email, stored for quick lookup of related addresses #: Read-only, accessible via the :property:`domain` property - _domain: Mapped[str | None] = sa.orm.mapped_column( + _domain: Mapped[str | None] = sa_orm.mapped_column( 'domain', sa.Unicode, nullable=True, index=True ) @@ -202,8 +211,8 @@ class EmailAddress(BaseMixin, Model): #: BLAKE2b 160-bit hash of :property:`email_normalized`. Kept permanently even if #: email is removed. SQLAlchemy type LargeBinary maps to PostgreSQL BYTEA. Despite #: the name, we're only storing 20 bytes - blake2b160 = immutable( - sa.orm.mapped_column( + blake2b160: Mapped[bytes] = immutable( + sa_orm.mapped_column( sa.LargeBinary, sa.CheckConstraint( 'LENGTH(blake2b160) = 20', @@ -217,12 +226,12 @@ class EmailAddress(BaseMixin, Model): #: BLAKE2b 160-bit hash of :property:`email_canonical`. Kept permanently for blocked #: email detection. Indexed but does not use a unique constraint because a+b@tld and #: a+c@tld are both a@tld canonically but can exist in records separately. - blake2b160_canonical = immutable( - sa.orm.mapped_column(sa.LargeBinary, nullable=False, index=True) + blake2b160_canonical: Mapped[bytes] = immutable( + sa_orm.mapped_column(sa.LargeBinary, nullable=False, index=True) ) #: Does this email address work? Records last known delivery state - _delivery_state: Mapped[int] = sa.orm.mapped_column( + _delivery_state: Mapped[int] = sa_orm.mapped_column( 'delivery_state', sa.Integer, StateManager.check_constraint( @@ -233,17 +242,17 @@ class EmailAddress(BaseMixin, Model): nullable=False, default=EMAIL_DELIVERY_STATE.UNKNOWN, ) - delivery_state = StateManager( + delivery_state = StateManager['EmailAddress']( '_delivery_state', EMAIL_DELIVERY_STATE, doc="Last known delivery state of this email address", ) #: Timestamp of last known delivery state - delivery_state_at: Mapped[datetime] = sa.orm.mapped_column( + delivery_state_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) #: Timestamp of last known recipient activity resulting from sent mail - active_at: Mapped[datetime | None] = sa.orm.mapped_column( + active_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) @@ -252,7 +261,7 @@ class EmailAddress(BaseMixin, Model): #: so a test for whether an address is blocked should use blake2b160_canonical to #: load the record. Other records with the same canonical hash _may_ exist without #: setting the flag due to a lack of database-side enforcement - _is_blocked: Mapped[bool] = sa.orm.mapped_column( + _is_blocked: Mapped[bool] = sa_orm.mapped_column( 'is_blocked', sa.Boolean, nullable=False, default=False ) @@ -291,6 +300,11 @@ class EmailAddress(BaseMixin, Model): ), ) + if TYPE_CHECKING: + used_in_account_email: Mapped[list[AccountEmail]] = relationship() + used_in_account_email_claim: Mapped[list[AccountEmailClaim]] = relationship() + used_in_ticket_participant: Mapped[list[TicketParticipant]] = relationship() + @hybrid_property def is_blocked(self) -> bool: """ @@ -356,7 +370,7 @@ def __format__(self, format_spec: str) -> str: """Format the email address.""" if not format_spec: return self.__str__() - return self.__str__().__format__(format_spec) + return format(self.__str__(), format_spec) def __repr__(self) -> str: """Debugging representation of the email address.""" @@ -472,7 +486,7 @@ def get_filter( email_hash: str | None = None, ) -> sa.ColumnElement[bool] | None: """ - Get an filter condition for retriving an :class:`EmailAddress`. + Get an filter condition for retrieving an :class:`EmailAddress`. Accepts an email address or a blake2b160 hash in either bytes or base58 form. Internally converts all lookups to a bytes-based hash lookup. Returns an @@ -527,14 +541,15 @@ def get( Internally converts an email-based lookup into a hash-based lookup. """ - return cls.query.filter( - cls.get_filter(email=email, blake2b160=blake2b160, email_hash=email_hash) - ).one_or_none() + email_filter = cls.get_filter( + email=email, blake2b160=blake2b160, email_hash=email_hash + ) + if email_filter is None: + return None + return cls.query.filter(email_filter).one_or_none() @classmethod - def get_canonical( - cls, email: str, is_blocked: bool | None = None - ) -> Query[EmailAddress]: + def get_canonical(cls, email: str, is_blocked: bool | None = None) -> Query[Self]: """ Get :class:`EmailAddress` instances matching the canonical representation. @@ -699,7 +714,7 @@ def is_valid_email_address( @declarative_mixin -class EmailAddressMixin: +class OptionalEmailAddressMixin: """ Mixin class for models that refer to :class:`EmailAddress`. @@ -725,7 +740,7 @@ class EmailAddressMixin: @classmethod def email_address_id(cls) -> Mapped[int | None]: """Foreign key to email_address table.""" - return sa.orm.mapped_column( + return sa_orm.mapped_column( sa.Integer, sa.ForeignKey('email_address.id', ondelete='SET NULL'), nullable=cls.__email_optional__, @@ -735,13 +750,14 @@ def email_address_id(cls) -> Mapped[int | None]: @declared_attr @classmethod - def email_address(cls) -> Mapped[EmailAddress]: + def email_address(cls) -> Mapped[EmailAddress | None]: """Instance of :class:`EmailAddress` as a relationship.""" backref_name = 'used_in_' + cls.__tablename__ EmailAddress.__backrefs__.add(backref_name) if cls.__email_for__ and cls.__email_is_exclusive__: EmailAddress.__exclusive_backrefs__.add(backref_name) - return relationship(EmailAddress, backref=backref_name) + with warnings.catch_warnings(action='ignore', category=ModelWarning): + return relationship(EmailAddress, backref=backref_name) @property def email(self) -> str | None: @@ -761,19 +777,19 @@ def email(self) -> str | None: return None @email.setter - def email(self, value: str | None) -> None: + def email(self, __value: str | None) -> None: """Set an email address.""" if self.__email_for__: - if value is not None: + if __value is not None: self.email_address = EmailAddress.add_for( - getattr(self, self.__email_for__), value + getattr(self, self.__email_for__), __value ) else: self.email_address = None else: - if value is not None: - self.email_address = EmailAddress.add(value) + if __value is not None: + self.email_address = EmailAddress.add(__value) else: self.email_address = None @@ -797,6 +813,37 @@ def transport_hash(self) -> str | None: ) +@declarative_mixin +class EmailAddressMixin(OptionalEmailAddressMixin): + """Non-optional version of :class:`OptionalEmailAddressMixin`.""" + + __email_optional__: ClassVar[bool] = False + + if TYPE_CHECKING: + + @declared_attr + @classmethod + def email_address_id(cls) -> Mapped[int]: # type: ignore[override] + ... + + @declared_attr + @classmethod + def email_address(cls) -> Mapped[EmailAddress]: # type: ignore[override] + ... + + @property # type: ignore[override] + def email(self) -> str: + ... + + @email.setter + def email(self, __value: str) -> None: + ... + + @property + def transport_hash(self) -> str: + ... + + auto_init_default(EmailAddress._delivery_state) # pylint: disable=protected-access auto_init_default(EmailAddress.delivery_state_at) auto_init_default(EmailAddress._is_blocked) # pylint: disable=protected-access @@ -860,7 +907,7 @@ def _send_refcount_event_remove( def _send_refcount_event_before_delete( - _mapper: Any, _connection: Any, target: EmailAddressMixin + _mapper: Any, _connection: Any, target: OptionalEmailAddressMixin ) -> None: if target.email_address: emailaddress_refcount_dropping.send(target.email_address) @@ -874,7 +921,7 @@ def _setup_refcount_events() -> None: def _email_address_mixin_set_validator( - target: EmailAddressMixin, + target: OptionalEmailAddressMixin, value: EmailAddress | None, old_value: EmailAddress | None, _initiator: Any, @@ -887,13 +934,14 @@ def _email_address_mixin_set_validator( raise EmailAddressInUseError("This email address it not available") -@event.listens_for(EmailAddressMixin, 'mapper_configured', propagate=True) +@event.listens_for(OptionalEmailAddressMixin, 'mapper_configured', propagate=True) def _email_address_mixin_configure_events( - _mapper: Any, cls: type[EmailAddressMixin] + _mapper: Any, cls: type[OptionalEmailAddressMixin] ) -> None: event.listen(cls.email_address, 'set', _email_address_mixin_set_validator) event.listen(cls, 'before_delete', _send_refcount_event_before_delete) if TYPE_CHECKING: - from .account import Account + from .account import Account, AccountEmail, AccountEmailClaim + from .sync_ticket import TicketParticipant diff --git a/funnel/models/geoname.py b/funnel/models/geoname.py index e107a497a..c8755361b 100644 --- a/funnel/models/geoname.py +++ b/funnel/models/geoname.py @@ -6,7 +6,7 @@ from collections.abc import Collection from datetime import date from decimal import Decimal -from typing import cast +from typing import Required, Self, TypedDict from sqlalchemy.dialects.postgresql import ARRAY @@ -18,10 +18,10 @@ GeonameModel, Mapped, Query, - backref, db, relationship, sa, + sa_orm, types, ) from .helpers import quote_autocomplete_like @@ -37,28 +37,34 @@ 'AS': 6255147, # Asia 'EU': 6255148, # Europe 'NA': 6255149, # North America - 'OC': 6255151, # Ocenia + 'OC': 6255151, # Oceania 'SA': 6255150, # South America 'AN': 6255152, # Antarctica } +class ParseLocationsDict(TypedDict, total=False): + token: Required[str] + special: bool + geoname: GeoName + + class GeoCountryInfo(BaseNameMixin, GeonameModel): """Geoname record for a country.""" __tablename__ = 'geo_country_info' - geonameid: Mapped[int] = sa.orm.synonym('id') + geonameid: Mapped[int] = sa_orm.synonym('id') geoname: Mapped[GeoName | None] = relationship( - 'GeoName', uselist=False, - primaryjoin='GeoCountryInfo.id == foreign(GeoName.id)', - backref='has_country', + viewonly=True, + 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] capital: Mapped[str | None] @@ -71,10 +77,10 @@ class GeoCountryInfo(BaseNameMixin, GeonameModel): 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( + languages: Mapped[list[str] | None] = sa_orm.mapped_column( ARRAY(sa.Unicode, dimensions=1) ) - neighbours: Mapped[list[str] | None] = sa.orm.mapped_column( + neighbours: Mapped[list[str] | None] = sa_orm.mapped_column( ARRAY(sa.CHAR(2), dimensions=1) ) equivalent_fips_code: Mapped[types.str3] @@ -97,21 +103,20 @@ class GeoAdmin1Code(BaseMixin, GeonameModel): __tablename__ = 'geo_admin1_code' - geonameid: Mapped[int] = sa.orm.synonym('id') - geoname: Mapped[GeoName] = relationship( - 'GeoName', + geonameid: Mapped[int] = sa_orm.synonym('id') + geoname: Mapped[GeoName | None] = relationship( uselist=False, - primaryjoin='GeoAdmin1Code.id == foreign(GeoName.id)', - backref='has_admin1code', + primaryjoin=lambda: GeoAdmin1Code.id == sa_orm.foreign(GeoName.id), viewonly=True, + back_populates='has_admin1code', ) - title: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - ascii_title: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - country_id: Mapped[str | None] = sa.orm.mapped_column( + title: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + ascii_title: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + country_id: Mapped[str | None] = sa_orm.mapped_column( 'country', sa.CHAR(2), sa.ForeignKey('geo_country_info.iso_alpha2') ) - country: Mapped[GeoCountryInfo | None] = relationship('GeoCountryInfo') - admin1_code: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) + country: Mapped[GeoCountryInfo | None] = relationship() + admin1_code: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) def __repr__(self) -> str: """Return representation.""" @@ -123,22 +128,21 @@ class GeoAdmin2Code(BaseMixin, GeonameModel): __tablename__ = 'geo_admin2_code' - geonameid: Mapped[int] = sa.orm.synonym('id') + geonameid: Mapped[int] = sa_orm.synonym('id') geoname: Mapped[GeoName] = relationship( - 'GeoName', uselist=False, - primaryjoin='GeoAdmin2Code.id == foreign(GeoName.id)', - backref='has_admin2code', viewonly=True, + primaryjoin=lambda: GeoAdmin2Code.id == sa_orm.foreign(GeoName.id), + back_populates='has_admin2code', ) - title: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - ascii_title: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - country_id: Mapped[str | None] = sa.orm.mapped_column( + title: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + ascii_title: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + country_id: Mapped[str | None] = sa_orm.mapped_column( 'country', sa.CHAR(2), sa.ForeignKey('geo_country_info.iso_alpha2') ) - country: Mapped[GeoCountryInfo | None] = relationship('GeoCountryInfo') - admin1_code: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - admin2_code: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) + country: Mapped[GeoCountryInfo | None] = relationship() + admin1_code: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + admin2_code: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) def __repr__(self) -> str: """Return representation.""" @@ -150,56 +154,78 @@ class GeoName(BaseNameMixin, GeonameModel): __tablename__ = 'geo_name' - geonameid: Mapped[int] = sa.orm.synonym('id') - ascii_title: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - latitude: Mapped[Decimal | None] = sa.orm.mapped_column(sa.Numeric) - longitude: Mapped[Decimal | None] = sa.orm.mapped_column(sa.Numeric) - fclass: Mapped[str | None] = sa.orm.mapped_column(sa.CHAR(1)) - fcode: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - country_id: Mapped[str | None] = sa.orm.mapped_column( + geonameid: Mapped[int] = sa_orm.synonym('id') + ascii_title: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + latitude: Mapped[Decimal | None] = sa_orm.mapped_column(sa.Numeric) + longitude: Mapped[Decimal | None] = sa_orm.mapped_column(sa.Numeric) + fclass: Mapped[str | None] = sa_orm.mapped_column(sa.CHAR(1)) + fcode: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + country_id: Mapped[str | None] = sa_orm.mapped_column( 'country', sa.CHAR(2), sa.ForeignKey('geo_country_info.iso_alpha2') ) - country: Mapped[GeoCountryInfo | None] = relationship('GeoCountryInfo') - cc2: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - admin1: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) + country: Mapped[GeoCountryInfo | None] = relationship() + cc2: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + admin1: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) admin1_ref: Mapped[GeoAdmin1Code | None] = relationship( - 'GeoAdmin1Code', uselist=False, - primaryjoin='and_(GeoName.country_id == foreign(GeoAdmin1Code.country_id), ' - 'GeoName.admin1 == foreign(GeoAdmin1Code.admin1_code))', + primaryjoin=lambda: sa.and_( + GeoName.country_id == sa_orm.foreign(GeoAdmin1Code.country_id), + GeoName.admin1 == sa_orm.foreign(GeoAdmin1Code.admin1_code), + ), viewonly=True, ) - admin1_id: Mapped[int | None] = sa.orm.mapped_column( + admin1_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('geo_admin1_code.id'), nullable=True ) admin1code: Mapped[GeoAdmin1Code | None] = relationship( - 'GeoAdmin1Code', uselist=False, foreign_keys=[admin1_id] + uselist=False, foreign_keys=[admin1_id] ) - admin2: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) + admin2: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) admin2_ref: Mapped[GeoAdmin2Code | None] = relationship( - 'GeoAdmin2Code', uselist=False, - primaryjoin='and_(GeoName.country_id == foreign(GeoAdmin2Code.country_id), ' - 'GeoName.admin1 == foreign(GeoAdmin2Code.admin1_code), ' - 'GeoName.admin2 == foreign(GeoAdmin2Code.admin2_code))', + primaryjoin=lambda: sa.and_( + GeoName.country_id == sa_orm.foreign(GeoAdmin2Code.country_id), + GeoName.admin1 == sa_orm.foreign(GeoAdmin2Code.admin1_code), + GeoName.admin2 == sa_orm.foreign(GeoAdmin2Code.admin2_code), + ), viewonly=True, ) - admin2_id: Mapped[int | None] = sa.orm.mapped_column( + admin2_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('geo_admin2_code.id'), nullable=True ) admin2code: Mapped[GeoAdmin2Code | None] = relationship( - 'GeoAdmin2Code', uselist=False, foreign_keys=[admin2_id] + uselist=False, foreign_keys=[admin2_id] ) - admin4: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - admin3: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - population: Mapped[int | None] = sa.orm.mapped_column(sa.BigInteger) - elevation: Mapped[int | None] = sa.orm.mapped_column(sa.Integer) + admin4: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + admin3: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + population: Mapped[int | None] = sa_orm.mapped_column(sa.BigInteger) + elevation: Mapped[int | None] = sa_orm.mapped_column(sa.Integer) #: Digital Elevation Model - dem: Mapped[int | None] = sa.orm.mapped_column(sa.Integer) - timezone: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode) - moddate: Mapped[date | None] = sa.orm.mapped_column(sa.Date) + dem: Mapped[int | None] = sa_orm.mapped_column(sa.Integer) + timezone: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) + moddate: Mapped[date | None] = sa_orm.mapped_column(sa.Date) + + has_country: Mapped[GeoCountryInfo | None] = relationship( + uselist=False, + viewonly=True, + primaryjoin=lambda: GeoCountryInfo.id == sa_orm.foreign(GeoName.id), + back_populates='geoname', + ) + has_admin1code: Mapped[GeoAdmin1Code | None] = relationship( + uselist=False, + viewonly=True, + primaryjoin=lambda: GeoAdmin1Code.id == sa_orm.foreign(GeoName.id), + back_populates='geoname', + ) + has_admin2code: Mapped[GeoAdmin2Code | None] = relationship( + uselist=False, + viewonly=True, + primaryjoin=lambda: GeoAdmin2Code.id == sa_orm.foreign(GeoName.id), + back_populates='geoname', + ) + alternate_titles: Mapped[list[GeoAltName]] = relationship(back_populates='geoname') __table_args__ = ( sa.Index( @@ -339,7 +365,11 @@ def related_geonames(self) -> dict[str, GeoName]: related: dict[str, GeoName] = {} if self.admin2code and self.admin2code.geonameid != self.geonameid: related['admin2'] = self.admin2code.geoname - if self.admin1code and self.admin1code.geonameid != self.geonameid: + if ( + self.admin1code + and self.admin1code.geonameid != self.geonameid + and self.admin1code.geoname is not None + ): related['admin1'] = self.admin1code.geoname if ( self.country @@ -450,7 +480,7 @@ def parse_locations( special: list[str] | None = None, lang: str | None = None, bias: list[str] | None = None, - ): + ) -> list[ParseLocationsDict]: """ Parse a string and return annotations marking all identified locations. @@ -462,12 +492,15 @@ def parse_locations( special = [s.lower() for s in special] if special else [] if bias is None: bias = [] - tokens = NOWORDS_RE.split(q) + while '' in bias: + bias.remove('') + bias = [each.upper() for each in bias] + tokens: list[str] = NOWORDS_RE.split(q) while '' in tokens: tokens.remove('') # Remove blank tokens from beginning and end ltokens = [t.lower() for t in tokens] - results: list[dict[str, object]] = [] - counter = 0 + results: list[ParseLocationsDict] = [] + counter: int = 0 limit = len(tokens) while counter < limit: token = tokens[counter] @@ -486,13 +519,13 @@ def parse_locations( sa.or_(GeoAltName.lang == lang, GeoAltName.lang.is_(None)) ) .options( - sa.orm.joinedload(GeoAltName.geoname).joinedload( + sa_orm.joinedload(GeoAltName.geoname).joinedload( GeoName.country ), - sa.orm.joinedload(GeoAltName.geoname).joinedload( + sa_orm.joinedload(GeoAltName.geoname).joinedload( GeoName.admin1code ), - sa.orm.joinedload(GeoAltName.geoname).joinedload( + sa_orm.joinedload(GeoAltName.geoname).joinedload( GeoName.admin2code ), ) @@ -506,13 +539,13 @@ def parse_locations( ) ) .options( - sa.orm.joinedload(GeoAltName.geoname).joinedload( + sa_orm.joinedload(GeoAltName.geoname).joinedload( GeoName.country ), - sa.orm.joinedload(GeoAltName.geoname).joinedload( + sa_orm.joinedload(GeoAltName.geoname).joinedload( GeoName.admin1code ), - sa.orm.joinedload(GeoAltName.geoname).joinedload( + sa_orm.joinedload(GeoAltName.geoname).joinedload( GeoName.admin2code ), ) @@ -526,7 +559,7 @@ def parse_locations( candidates = [ (NOWORDS_RE.split(m.title.lower()), m) for m in matches ] - fullmatch = [] + fullmatch: list[tuple[int, GeoAltName]] = [] for mtokens, match in candidates: if mtokens == ltokens[counter : counter + len(mtokens)]: fullmatch.append((len(mtokens), match)) @@ -538,14 +571,11 @@ def parse_locations( # (d) population accepted.sort( key=lambda a: ( - { - v: k - for k, v in enumerate( - reversed(cast(list[str], bias)) - ) - }.get(a.geoname.country_id, -1), + {v: k for k, v in enumerate(reversed(bias))}.get( + a.geoname.country_id or '', -1 + ), {lang: 0}.get(a.lang, 1), - {'A': 1, 'P': 2}.get(a.geoname.fclass, 0), + {'A': 1, 'P': 2}.get(a.geoname.fclass or '', 0), a.geoname.population, ), reverse=True, @@ -568,7 +598,7 @@ def parse_locations( return results @classmethod - def autocomplete(cls, prefix: str, lang: str | None = None) -> Query[GeoName]: + def autocomplete(cls, prefix: str, lang: str | None = None) -> Query[Self]: """ Autocomplete a geoname record. @@ -596,21 +626,18 @@ class GeoAltName(BaseMixin, GeonameModel): __tablename__ = 'geo_alt_name' - geonameid: Mapped[int] = sa.orm.mapped_column( + geonameid: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('geo_name.id'), nullable=False ) - geoname: Mapped[GeoName] = relationship( - GeoName, - backref=backref('alternate_titles', cascade='all, delete-orphan'), - ) - lang: Mapped[str | None] = sa.orm.mapped_column( + geoname: Mapped[GeoName] = relationship(back_populates='alternate_titles') + lang: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode, nullable=True, index=True ) - title: Mapped[str] = sa.orm.mapped_column(sa.Unicode, nullable=False) - is_preferred_name: Mapped[str] = sa.orm.mapped_column(sa.Boolean, nullable=False) - is_short_name: Mapped[bool] = sa.orm.mapped_column(sa.Boolean, nullable=False) - is_colloquial: Mapped[bool] = sa.orm.mapped_column(sa.Boolean, nullable=False) - is_historic: Mapped[bool] = sa.orm.mapped_column(sa.Boolean, nullable=False) + title: Mapped[str] = sa_orm.mapped_column(sa.Unicode, nullable=False) + is_preferred_name: Mapped[bool] = sa_orm.mapped_column(sa.Boolean, nullable=False) + is_short_name: Mapped[bool] = sa_orm.mapped_column(sa.Boolean, nullable=False) + is_colloquial: Mapped[bool] = sa_orm.mapped_column(sa.Boolean, nullable=False) + is_historic: Mapped[bool] = sa_orm.mapped_column(sa.Boolean, nullable=False) __table_args__ = ( sa.Index( diff --git a/funnel/models/helpers.py b/funnel/models/helpers.py index 9a38014e5..105fc0755 100644 --- a/funnel/models/helpers.py +++ b/funnel/models/helpers.py @@ -4,14 +4,16 @@ import os.path import re +import warnings from collections.abc import Callable, Iterable from dataclasses import dataclass from textwrap import dedent -from typing import Any, ClassVar, TypeVar, cast +from typing import Any, ClassVar, TypeVar, get_type_hints from better_profanity import profanity from furl import furl from markupsafe import Markup, escape as html_escape +from sqlalchemy import event as sa_event from sqlalchemy.dialects.postgresql import TSQUERY from sqlalchemy.dialects.postgresql.base import ( RESERVED_WORDS as POSTGRESQL_RESERVED_WORDS, @@ -23,7 +25,7 @@ from .. import app from ..typing import T from ..utils import MarkdownConfig, MarkdownString, markdown_escape -from . import Model, UrlType, sa +from . import Model, UrlType, sa, sa_orm __all__ = [ 'RESERVED_NAMES', @@ -141,7 +143,7 @@ class PasswordCheckType: (guesses < 10^3) * 1: very guessable: protection from throttled online attacks (guesses < 10^6) - * 2: somewhat guessable: protection from unthrottled online attacks + * 2: somewhat guessable: protection from un-throttled online attacks (guesses < 10^8) * 3: safely unguessable: moderate protection from offline slow-hash scenario (guesses < 10^10) @@ -197,6 +199,7 @@ def check_password_strength( profanity.add_censor_words([_w.strip() for _w in badwordfile.readlines()]) +# Used as a delimiter in search results when showing a preview from multiple fields visual_field_delimiter = ' ¦ ' @@ -222,7 +225,11 @@ def decorator(attr: T) -> T: # None or '' not allowed raise ValueError(f"Could not determine name for {attr!r}") if use_name in cls.__dict__: - raise AttributeError(f"{cls.__name__} already has attribute {use_name}") + raise AttributeError( + f"{cls.__name__} already has attribute {use_name}", + name=use_name, + obj=cls, + ) setattr(cls, use_name, attr) return attr @@ -233,7 +240,7 @@ def decorator(attr: T) -> T: TempType = TypeVar('TempType', bound=type) -def reopen(cls: ReopenedType) -> Callable[[TempType], ReopenedType]: +def reopen(cls: ReopenedType) -> Callable[[type], ReopenedType]: """ Move the contents of the decorated class into an existing class and return it. @@ -267,7 +274,7 @@ def new_property(self): properties that do more processing. """ - def decorator(temp_cls: TempType) -> ReopenedType: + def decorator(temp_cls: type) -> ReopenedType: if temp_cls.__bases__ != (object,): raise TypeError("Reopened class cannot add base classes") if temp_cls.__class__ is not type: @@ -279,7 +286,25 @@ def decorator(temp_cls: TempType) -> ReopenedType: '__setattr__', '__delattr__', }.intersection(set(temp_cls.__dict__.keys())): - raise TypeError("Reopened class contains unsupported __attributes__") + raise TypeError("Reopened class contains unsupported __dunder__ attributes") + if '__annotations__' in temp_cls.__dict__: + # Temp class annotations must be un-stringified as they may refer to names + # not available in the reopened class's namespace + try: + annotations = get_type_hints(temp_cls, include_extras=True) + except NameError as exc: + warnings.warn( + f"{temp_cls.__qualname__} has a forward annotation that cannot be" + f" resolved. Annotations in {cls.__qualname__} may not be usable at" + f" runtime: {exc}", + RuntimeWarning, + stacklevel=2, + ) + annotations = temp_cls.__annotations__ + if '__annotations__' not in cls.__dict__: + cls.__annotations__ = annotations + else: + cls.__annotations__ = annotations | cls.__annotations__ for attr, value in list(temp_cls.__dict__.items()): # Skip the standard Python attributes, process the rest if attr not in ( @@ -291,14 +316,21 @@ def decorator(temp_cls: TempType) -> ReopenedType: ): # Refuse to overwrite existing attributes if hasattr(cls, attr): - raise AttributeError(f"{cls.__name__} already has attribute {attr}") + # At this time we've already merged __annotations__, so there's no + # good way to recover from this error -- it's effectively fatal and + # requires code rewrite. This is however an implicit assumption when + # using @reopen -- it should not be within a try block. + raise AttributeError( + f"{cls.__qualname__} already has attribute {attr!r}", + name=attr, + obj=cls, + ) # All good? Copy the attribute over... setattr(cls, attr, value) + if hasattr(value, '__set_name__'): + value.__set_name__(cls, attr) # ...And remove it from the temporary class delattr(temp_cls, attr) - # Merge typing annotations - elif attr == '__annotations__': - cls.__annotations__.update(value) # Return the original class. Leave the temporary class to the garbage collector return cls @@ -372,14 +404,11 @@ def quote_autocomplete_like(prefix: str, midway: bool = False) -> str: return lstrip_like_query -def quote_autocomplete_tsquery(prefix: str) -> TSQUERY: +def quote_autocomplete_tsquery(prefix: str) -> sa.Cast[str]: """Return a PostgreSQL tsquery suitable for autocomplete-type matches.""" - return cast( + return sa.cast( + sa.func.concat(sa.func.phraseto_tsquery('simple', prefix or ''), ':*'), TSQUERY, - sa.func.cast( - sa.func.concat(sa.func.phraseto_tsquery('simple', prefix or ''), ':*'), - TSQUERY, - ), ) @@ -391,7 +420,7 @@ def add_search_trigger(model: type[Model], column_name: str) -> dict[str, str]: class MyModel(Model): ... - search_vector: Mapped[str] = sa.orm.mapped_column( + search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( 'name', 'title', *indexed_columns, weights={'name': 'A', 'title': 'B'}, @@ -482,13 +511,13 @@ class MyModel(Model): ''' ) - sa.event.listen( + sa_event.listen( model.__table__, 'after_create', sa.DDL(trigger_function).execute_if(dialect='postgresql'), ) - sa.event.listen( + sa_event.listen( model.__table__, 'before_drop', sa.DDL(drop_statement).execute_if(dialect='postgresql'), @@ -613,7 +642,7 @@ def __markdown_format__(self, format_spec: str) -> str: """Implement format_spec support as required by MarkdownString.""" # This call's MarkdownString's __format__ instead of __markdown_format__ as the # content has not been manipulated from the source string - return self.__markdown__().__format__(format_spec) + return format(self.__markdown__(), format_spec) def __html__(self) -> str: """Return HTML representation.""" @@ -623,7 +652,7 @@ def __html_format__(self, format_spec: str) -> str: """Implement format_spec support as required by Markup.""" # This call's Markup's __format__ instead of __html_format__ as the # content has not been manipulated from the source string - return self.__html__().__format__(format_spec) + return format(self.__html__(), format_spec) # Return a Markup string of the HTML @property @@ -693,16 +722,16 @@ def create( deferred: bool = False, deferred_group: str | None = None, **kwargs, - ) -> tuple[sa.orm.Composite[_MC], Mapped[str], Mapped[str]]: + ) -> tuple[sa_orm.Composite[_MC], Mapped[str], Mapped[str]]: """Create a composite column and backing individual columns.""" - col_text = sa.orm.mapped_column( + col_text = sa_orm.mapped_column( name + '_text', sa.UnicodeText, deferred=deferred, deferred_group=deferred_group, **kwargs, ) - col_html = sa.orm.mapped_column( + col_html = sa_orm.mapped_column( name + '_html', sa.UnicodeText, deferred=deferred, diff --git a/funnel/models/label.py b/funnel/models/label.py index 16cfca13b..3f0a4c150 100644 --- a/funnel/models/label.py +++ b/funnel/models/label.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from sqlalchemy.ext.orderinglist import OrderingList, ordering_list from coaster.sqlalchemy import with_roles @@ -14,11 +16,10 @@ hybrid_property, relationship, sa, + sa_orm, ) -from .helpers import add_search_trigger, reopen, visual_field_delimiter -from .project import Project +from .helpers import add_search_trigger, visual_field_delimiter from .project_membership import project_child_role_map -from .proposal import Proposal proposal_label = sa.Table( 'proposal_label', @@ -45,21 +46,21 @@ class Label(BaseScopedNameMixin, Model): __tablename__ = 'label' - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False ) # Backref from project is defined in the Project model with an ordering list project: Mapped[Project] = with_roles( - relationship(Project), grants_via={None: project_child_role_map} + relationship(), grants_via={None: project_child_role_map} ) # `parent` is required for # :meth:`~coaster.sqlalchemy.mixins.BaseScopedNameMixin.make_name()` - parent: Mapped[Project] = sa.orm.synonym('project') + parent: Mapped[Project] = sa_orm.synonym('project') #: Parent label's id. Do not write to this column directly, as we don't have the #: ability to : validate the value within the app. Always use the :attr:`main_label` #: relationship. - main_label_id: Mapped[int | None] = sa.orm.mapped_column( + main_label_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('label.id', ondelete='CASCADE'), index=True, @@ -71,7 +72,7 @@ class Label(BaseScopedNameMixin, Model): # See https://docs.sqlalchemy.org/en/13/orm/self_referential.html options: Mapped[OrderingList[Label]] = relationship( back_populates='main_label', - order_by='Label.seq', + order_by=lambda: Label.seq, passive_deletes=True, collection_class=ordering_list('seq', count_from=1), ) @@ -81,39 +82,39 @@ class Label(BaseScopedNameMixin, Model): # add_primary_relationship) #: Sequence number for this label, used in UI for ordering - seq: Mapped[int] = sa.orm.mapped_column(sa.Integer, nullable=False) + seq: Mapped[int] = sa_orm.mapped_column(sa.Integer, nullable=False) # A single-line description of this label, shown when picking labels (optional) - description: Mapped[str] = sa.orm.mapped_column( + description: Mapped[str] = sa_orm.mapped_column( sa.UnicodeText, nullable=False, default='' ) #: Icon for displaying in space-constrained UI. Contains one emoji symbol. #: Since emoji can be composed from multiple symbols, there is no length #: limit imposed here - icon_emoji: Mapped[str | None] = sa.orm.mapped_column(sa.UnicodeText, nullable=True) + icon_emoji: Mapped[str | None] = sa_orm.mapped_column(sa.UnicodeText, nullable=True) #: Restricted mode specifies that this label may only be applied by someone with #: an editorial role (TODO: name the role). If this label is a parent, it applies #: to all its children - _restricted: Mapped[bool] = sa.orm.mapped_column( + _restricted: Mapped[bool] = sa_orm.mapped_column( 'restricted', sa.Boolean, nullable=False, default=False ) #: Required mode signals to UI that if this label is a parent, one of its #: children must be mandatorily applied to the proposal. The value of this #: field must be ignored if the label is not a parent - _required: Mapped[bool] = sa.orm.mapped_column( + _required: Mapped[bool] = sa_orm.mapped_column( 'required', sa.Boolean, nullable=False, default=False ) #: Archived mode specifies that the label is no longer available for use #: although all the previous records will stay in database. - _archived: Mapped[bool] = sa.orm.mapped_column( + _archived: Mapped[bool] = sa_orm.mapped_column( 'archived', sa.Boolean, nullable=False, default=False ) - search_vector: Mapped[str] = sa.orm.mapped_column( + search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( 'name', 'title', @@ -130,7 +131,7 @@ class Label(BaseScopedNameMixin, Model): #: Proposals that this label is attached to proposals: Mapped[list[Proposal]] = relationship( - Proposal, secondary=proposal_label, back_populates='labels' + secondary=proposal_label, back_populates='labels' ) __table_args__ = ( @@ -339,7 +340,7 @@ def __getattr__(self, name: str) -> bool | str | None: Label.name == name, Label.project == self._obj.project ).one_or_none() if label is None: - raise AttributeError + raise AttributeError(f"No label {name} in {self._obj.project}") if not label.has_options: return label in self._obj.labels @@ -357,7 +358,7 @@ def __setattr__(self, name: str, value: bool) -> None: Label._archived.is_(False), ).one_or_none() if label is None: - raise AttributeError + raise AttributeError(f"No label {name} in {self._obj.project}") if not label.has_options: if value is True: @@ -396,31 +397,7 @@ def __get__(self, obj, cls=None) -> ProposalLabelProxyWrapper | ProposalLabelPro return self -@reopen(Project) -class __Project: - labels: Mapped[list[Label]] = relationship( - Label, - primaryjoin=sa.and_( - Label.project_id == Project.id, - Label.main_label_id.is_(None), - Label._archived.is_(False), # pylint: disable=protected-access - ), - order_by=Label.seq, - viewonly=True, - ) - all_labels: Mapped[list[Label]] = relationship( - Label, - collection_class=ordering_list('seq', count_from=1), - back_populates='project', - ) - - -@reopen(Proposal) -class __Proposal: - #: For reading and setting labels from the edit form - formlabels = ProposalLabelProxy() - - labels: Mapped[list[Label]] = with_roles( - relationship(Label, secondary=proposal_label, back_populates='proposals'), - read={'all'}, - ) +# Tail imports +if TYPE_CHECKING: + from .project import Project + from .proposal import Proposal diff --git a/funnel/models/login_session.py b/funnel/models/login_session.py index 73c8bee53..f983a4912 100644 --- a/funnel/models/login_session.py +++ b/funnel/models/login_session.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from typing import TYPE_CHECKING from coaster.utils import utcnow @@ -13,12 +14,11 @@ Mapped, Model, UuidMixin, - backref, relationship, sa, + sa_orm, ) from .account import Account -from .helpers import reopen __all__ = [ 'LoginSession', @@ -34,6 +34,10 @@ class LoginSessionError(Exception): """Base exception for user session errors.""" + def __init__(self, login_session: LoginSession, *args) -> None: + self.login_session = login_session + super().__init__(login_session, *args) + class LoginSessionExpiredError(LoginSessionError): """This user session has expired and cannot be marked as currently active.""" @@ -86,43 +90,50 @@ class LoginSessionInactiveUserError(LoginSessionError): class LoginSession(UuidMixin, BaseMixin, Model): __tablename__ = 'login_session' - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) - account: Mapped[Account] = relationship( - Account, - backref=backref('all_login_sessions', cascade='all', lazy='dynamic'), - ) + account: Mapped[Account] = relationship(back_populates='all_login_sessions') #: User's last known IP address - ipaddr: Mapped[str] = sa.orm.mapped_column(sa.String(45), nullable=False) + ipaddr: Mapped[str] = sa_orm.mapped_column(sa.String(45), nullable=False) #: City geonameid from IP address - geonameid_city: Mapped[int | None] = sa.orm.mapped_column(sa.Integer, nullable=True) + geonameid_city: Mapped[int | None] = sa_orm.mapped_column(sa.Integer, nullable=True) #: State/subdivision geonameid from IP address - geonameid_subdivision: Mapped[int | None] = sa.orm.mapped_column( + geonameid_subdivision: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, nullable=True ) #: Country geonameid from IP address - geonameid_country: Mapped[int | None] = sa.orm.mapped_column( + geonameid_country: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, nullable=True ) #: User's network, from IP address - geoip_asn: Mapped[int | None] = sa.orm.mapped_column(sa.Integer, nullable=True) + geoip_asn: Mapped[int | None] = sa_orm.mapped_column(sa.Integer, nullable=True) #: User agent - user_agent: Mapped[str] = sa.orm.mapped_column(sa.UnicodeText, nullable=False) + user_agent: Mapped[str] = sa_orm.mapped_column(sa.UnicodeText, nullable=False) #: The login service that was used to make this session - login_service: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode, nullable=True) + login_service: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True) - accessed_at: Mapped[datetime] = sa.orm.mapped_column( + accessed_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False ) - revoked_at: Mapped[datetime | None] = sa.orm.mapped_column( + revoked_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) - sudo_enabled_at: Mapped[datetime] = sa.orm.mapped_column( + sudo_enabled_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) + # --- Backrefs + auth_clients: DynamicMapped[AuthClient] = relationship( + lazy='dynamic', + secondary=auth_client_login_session, + back_populates='login_sessions', + ) + authtokens: DynamicMapped[AuthToken] = relationship( + lazy='dynamic', back_populates='login_session' + ) + def __repr__(self) -> str: """Represent :class:`UserSession` as a string.""" return f'' @@ -183,16 +194,6 @@ def authenticate(cls, buid: str, silent: bool = False) -> LoginSession | None: return login_session -@reopen(Account) -class __Account: - active_login_sessions: DynamicMapped[LoginSession] = relationship( - LoginSession, - lazy='dynamic', - primaryjoin=sa.and_( - LoginSession.account_id == Account.id, - LoginSession.accessed_at > sa.func.utcnow() - LOGIN_SESSION_VALIDITY_PERIOD, - LoginSession.revoked_at.is_(None), - ), - order_by=LoginSession.accessed_at.desc(), - viewonly=True, - ) +# Tail imports +if TYPE_CHECKING: + from .auth_client import AuthClient, AuthToken diff --git a/funnel/models/mailer.py b/funnel/models/mailer.py index 035b8061b..9dec9157a 100644 --- a/funnel/models/mailer.py +++ b/funnel/models/mailer.py @@ -6,7 +6,7 @@ from collections.abc import Collection, Iterator from datetime import datetime from enum import IntEnum -from typing import Any +from typing import Any, Self from uuid import UUID from flask import request @@ -28,9 +28,9 @@ db, relationship, sa, + sa_orm, ) from .account import Account -from .helpers import reopen from .types import jsonb __all__ = [ @@ -72,34 +72,37 @@ class Mailer(BaseNameMixin, Model): __tablename__ = 'mailer' - user_uuid: Mapped[UUID] = sa.orm.mapped_column(sa.ForeignKey('account.uuid')) - user: Mapped[Account] = relationship(Account, back_populates='mailers') - status: Mapped[int] = sa.orm.mapped_column( + user_uuid: Mapped[UUID] = sa_orm.mapped_column(sa.ForeignKey('account.uuid')) + user: Mapped[Account] = relationship(back_populates='mailers') + status: Mapped[int] = sa_orm.mapped_column( sa.Integer, nullable=False, default=MailerState.DRAFT ) - _fields: Mapped[str] = sa.orm.mapped_column( + _fields: Mapped[str] = sa_orm.mapped_column( 'fields', sa.UnicodeText, nullable=False, default='' ) - trackopens: Mapped[bool] = sa.orm.mapped_column( + trackopens: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) - stylesheet: Mapped[str] = sa.orm.mapped_column( + stylesheet: Mapped[str] = sa_orm.mapped_column( sa.UnicodeText, nullable=False, default='' ) - _cc: Mapped[str] = sa.orm.mapped_column('cc', sa.UnicodeText, nullable=True) - _bcc: Mapped[str] = sa.orm.mapped_column('bcc', sa.UnicodeText, nullable=True) + _cc: Mapped[str] = sa_orm.mapped_column('cc', sa.UnicodeText, nullable=True) + _bcc: Mapped[str] = sa_orm.mapped_column('bcc', sa.UnicodeText, nullable=True) recipients: DynamicMapped[MailerRecipient] = relationship( lazy='dynamic', back_populates='mailer', - cascade='all, delete-orphan', - order_by='(MailerRecipient.draft_id, MailerRecipient._fullname,' - ' MailerRecipient._firstname, MailerRecipient._lastname)', + order_by=lambda: ( + # pylint: disable=protected-access + MailerRecipient.draft_id, + MailerRecipient._fullname, + MailerRecipient._firstname, + MailerRecipient._lastname, + ), ) drafts: Mapped[list[MailerDraft]] = relationship( back_populates='mailer', - cascade='all, delete-orphan', - order_by='MailerDraft.url_id', + order_by=lambda: MailerDraft.url_id, ) def __init__(self, **kwargs: Any) -> None: @@ -196,18 +199,18 @@ class MailerDraft(BaseScopedIdMixin, Model): __tablename__ = 'mailer_draft' - mailer_id: Mapped[int] = sa.orm.mapped_column( + mailer_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('mailer.id'), nullable=False ) - mailer: Mapped[Mailer] = relationship(Mailer, back_populates='drafts') - parent: Mapped[Mailer] = sa.orm.synonym('mailer') - revision_id: Mapped[int] = sa.orm.synonym('url_id') + mailer: Mapped[Mailer] = relationship(back_populates='drafts') + parent: Mapped[Mailer] = sa_orm.synonym('mailer') + revision_id: Mapped[int] = sa_orm.synonym('url_id') - subject: Mapped[str] = sa.orm.mapped_column( + subject: Mapped[str] = sa_orm.mapped_column( sa.Unicode(250), nullable=False, default="", deferred=True ) - template: Mapped[str] = sa.orm.mapped_column( + template: Mapped[str] = sa_orm.mapped_column( sa.UnicodeText, nullable=False, default="", deferred=True ) @@ -226,81 +229,81 @@ class MailerRecipient(BaseScopedIdMixin, Model): __tablename__ = 'mailer_recipient' # Mailer this recipient is a part of - mailer_id: Mapped[int] = sa.orm.mapped_column(sa.ForeignKey('mailer.id')) - mailer: Mapped[Mailer] = relationship(Mailer, back_populates='recipients') - parent: Mapped[Mailer] = sa.orm.synonym('mailer') + mailer_id: Mapped[int] = sa_orm.mapped_column(sa.ForeignKey('mailer.id')) + mailer: Mapped[Mailer] = relationship(back_populates='recipients') + parent: Mapped[Mailer] = sa_orm.synonym('mailer') - _fullname: Mapped[str | None] = sa.orm.mapped_column( + _fullname: Mapped[str | None] = sa_orm.mapped_column( 'fullname', sa.Unicode(80), nullable=True ) - _firstname: Mapped[str | None] = sa.orm.mapped_column( + _firstname: Mapped[str | None] = sa_orm.mapped_column( 'firstname', sa.Unicode(80), nullable=True ) - _lastname: Mapped[str | None] = sa.orm.mapped_column( + _lastname: Mapped[str | None] = sa_orm.mapped_column( 'lastname', sa.Unicode(80), nullable=True ) - _nickname: Mapped[str | None] = sa.orm.mapped_column( + _nickname: Mapped[str | None] = sa_orm.mapped_column( 'nickname', sa.Unicode(80), nullable=True ) - _email: Mapped[str] = sa.orm.mapped_column( + _email: Mapped[str] = sa_orm.mapped_column( 'email', sa.Unicode(80), nullable=False, index=True ) - md5sum: Mapped[str] = sa.orm.mapped_column( + md5sum: Mapped[str] = sa_orm.mapped_column( sa.String(32), nullable=False, index=True ) - data: Mapped[jsonb] = sa.orm.mapped_column() + data: Mapped[jsonb] = sa_orm.mapped_column() - is_sent: Mapped[bool] = sa.orm.mapped_column(default=False) + is_sent: Mapped[bool] = sa_orm.mapped_column(default=False) # Support email open tracking - opentoken: Mapped[str] = sa.orm.mapped_column( + opentoken: Mapped[str] = sa_orm.mapped_column( sa.Unicode(44), nullable=False, default=newsecret, unique=True ) - opened: Mapped[bool] = sa.orm.mapped_column( + opened: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) - opened_ipaddr: Mapped[str | None] = sa.orm.mapped_column( + opened_ipaddr: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode(45), nullable=True ) - opened_first_at: Mapped[datetime | None] = sa.orm.mapped_column( + opened_first_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) - opened_last_at: Mapped[datetime | None] = sa.orm.mapped_column( + opened_last_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) - opened_count: Mapped[int] = sa.orm.mapped_column( + opened_count: Mapped[int] = sa_orm.mapped_column( sa.Integer, nullable=False, default=0 ) # Support RSVP if the email requires it - rsvptoken: Mapped[str] = sa.orm.mapped_column( + rsvptoken: Mapped[str] = sa_orm.mapped_column( sa.Unicode(44), nullable=False, default=newsecret, unique=True ) # Y/N/M response - rsvp: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode(1), nullable=True) + rsvp: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode(1), nullable=True) # Customised template for this recipient - subject: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode(250), nullable=True) - template: Mapped[str | None] = sa.orm.mapped_column( + subject: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode(250), nullable=True) + template: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True, deferred=True ) # Rendered version of user's template, for archival - rendered_text: Mapped[str | None] = sa.orm.mapped_column( + rendered_text: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True, deferred=True ) - rendered_html: Mapped[str | None] = sa.orm.mapped_column( + rendered_html: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True, deferred=True ) # Draft of the mailer template that the custom template is linked to (for updating # before finalising) - draft_id: Mapped[int | None] = sa.orm.mapped_column( + draft_id: Mapped[int | None] = sa_orm.mapped_column( sa.ForeignKey('mailer_draft.id') ) - draft: Mapped[MailerDraft | None] = relationship(MailerDraft) + draft: Mapped[MailerDraft | None] = relationship() __table_args__ = (sa.UniqueConstraint('mailer_id', 'url_id'),) @@ -422,7 +425,7 @@ def custom_draft(self) -> bool: return self.draft is not None @classmethod - def custom_draft_in(cls, mailer: Mailer) -> list[MailerRecipient]: + def custom_draft_in(cls, mailer: Mailer) -> list[Self]: return ( cls.query.filter( cls.mailer == mailer, @@ -445,10 +448,3 @@ def custom_draft_in(cls, mailer: Mailer) -> list[MailerRecipient]: ) .all() ) - - -@reopen(Account) -class __Account: - mailers: Mapped[list[Mailer]] = relationship( - Mailer, back_populates='user', order_by='Mailer.updated_at.desc()' - ) diff --git a/funnel/models/membership_mixin.py b/funnel/models/membership_mixin.py index c42cbc83e..e3fc1c46b 100644 --- a/funnel/models/membership_mixin.py +++ b/funnel/models/membership_mixin.py @@ -4,7 +4,8 @@ from collections.abc import Callable, Iterable from datetime import datetime as datetime_type -from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Self, TypeVar from uuid import UUID from sqlalchemy import event @@ -26,6 +27,7 @@ hybrid_property, relationship, sa, + sa_orm, ) from .account import Account from .reorder_mixin import ReorderProtoMixin @@ -99,9 +101,9 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin[UUID]): __tablename__: str #: Parent column (declare as synonym of 'profile_id' or 'project_id' in #: subclasses) - parent_id: Mapped[int] + parent_id: Mapped[int] | None #: Parent object - parent: Mapped[Model | None] + parent: Mapped[Model] | None #: Subject of this membership (subclasses must define) member: Mapped[Account] @@ -117,7 +119,7 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin[UUID]): #: for records created when the member table was added to the database granted_at: Mapped[datetime_type] = with_roles( immutable( - sa.orm.mapped_column( + sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) ), @@ -125,13 +127,13 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin[UUID]): ) #: End time of membership, ordinarily a mirror of updated_at revoked_at: Mapped[datetime_type | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'member', 'editor'}, ) #: Record type record_type: Mapped[int] = with_roles( immutable( - sa.orm.mapped_column( + sa_orm.mapped_column( sa.Integer, StateManager.check_constraint('record_type', MEMBERSHIP_RECORD_TYPE), default=MEMBERSHIP_RECORD_TYPE.DIRECT_ADD, @@ -151,7 +153,7 @@ def record_type_label(self): @classmethod def revoked_by_id(cls) -> Mapped[int | None]: """Id of user who revoked the membership.""" - return sa.orm.mapped_column( + return sa_orm.mapped_column( sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=True ) @@ -171,7 +173,7 @@ def granted_by_id(cls) -> Mapped[int | None]: This is nullable only for historical data. New records always require a value for granted_by. """ - return sa.orm.mapped_column( + return sa_orm.mapped_column( sa.Integer, sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=cls.__null_granted_by__, @@ -375,7 +377,7 @@ class ImmutableUserMembershipMixin(ImmutableMembershipMixin): @classmethod def member_id(cls) -> Mapped[int]: """Foreign key column to account table.""" - return sa.orm.mapped_column( + return sa_orm.mapped_column( sa.Integer, sa.ForeignKey('account.id', ondelete='CASCADE'), nullable=False, @@ -393,7 +395,7 @@ def member(cls) -> Mapped[Account]: # type: ignore[override] @classmethod def user(cls) -> Mapped[Account]: """Legacy alias for member in this membership record.""" - return sa.orm.synonym('member') + return sa_orm.synonym('member') @declared_attr.directive @classmethod @@ -451,7 +453,7 @@ def copy_template(self: MembershipType, **kwargs) -> MembershipType: @classmethod def migrate_account(cls, old_account: Account, new_account: Account) -> None: """ - Migrate memberhip records from one account to another. + Migrate membership records from one account to another. If both accounts have active records, they are merged into a new record in the new account's favour. All revoked records for the old account are transferred to @@ -512,11 +514,7 @@ class ReorderMembershipProtoMixin(ReorderProtoMixin): #: However, it can be argued that relocating a sponsor in the list constitutes a #: change that must be recorded as a revision. We may need to change our opinion #: on `seq` being mutable in a future iteration. - @declared_attr - @classmethod - def seq(cls) -> Mapped[int]: - """Ordering sequence number.""" - return sa.orm.mapped_column(sa.Integer, nullable=False) + seq: Mapped[int] = sa_orm.mapped_column(nullable=False) @declared_attr.directive @classmethod @@ -580,7 +578,7 @@ class FrozenAttributionProtoMixin: if TYPE_CHECKING: member: Mapped[Account] - replace: Callable[..., FrozenAttributionType] + replace: Callable[..., Self] _local_data_only: bool @declared_attr @@ -588,7 +586,7 @@ class FrozenAttributionProtoMixin: def _title(cls) -> Mapped[str | None]: """Create optional attribution title for this membership record.""" return immutable( - sa.orm.mapped_column( + sa_orm.mapped_column( 'title', sa.Unicode, sa.CheckConstraint("title <> ''"), nullable=True ) ) @@ -597,7 +595,7 @@ def _title(cls) -> Mapped[str | None]: def title(self) -> str: """Attribution title for this record.""" if self._local_data_only: - # self._title may be None + # self._title may be None when returning local data return self._title # type: ignore[return-value] return self._title or self.member.title @@ -614,14 +612,10 @@ def pickername(self) -> str: return self._title if self._title else self.member.pickername @with_roles(call={'owner', 'member'}) - def freeze_member_attribution( - self: FrozenAttributionType, actor: Account - ) -> FrozenAttributionType: + def freeze_member_attribution(self, actor: Account) -> Self: """Freeze member attribution and return a replacement record.""" if self._title is None: - membership: FrozenAttributionType = self.replace( - actor=actor, title=self.member.title - ) + membership = self.replace(actor=actor, title=self.member.title) else: membership = self return membership @@ -643,6 +637,10 @@ class AmendMembership(Generic[MembershipType]): to any attribute listed as a data column. """ + membership: MembershipType + _actor: Account + _new: dict[str, Any] + def __init__(self, membership: MembershipType, actor: Account) -> None: """Create an amendment placeholder.""" if membership.revoked_at is not None: @@ -650,8 +648,8 @@ def __init__(self, membership: MembershipType, actor: Account) -> None: "This membership record has already been revoked" ) object.__setattr__(self, 'membership', membership) - object.__setattr__(self, '_new', {}) object.__setattr__(self, '_actor', actor) + object.__setattr__(self, '_new', {}) def __getattr__(self, attr: str) -> Any: """Get an attribute from the underlying record.""" @@ -662,7 +660,13 @@ def __getattr__(self, attr: str) -> Any: def __setattr__(self, attr: str, value: Any) -> None: """Set an amended value.""" if attr not in self.membership.__data_columns__: - raise AttributeError(f"{attr} cannot be set") + raise AttributeError( + f"{attr} cannot be set", + name=attr, + obj=SimpleNamespace( + **{_: None for _ in self.membership.__data_columns__} + ), + ) self._new[attr] = value def __enter__(self) -> AmendMembership: @@ -697,10 +701,14 @@ def _confirm_enumerated_mixins(_mapper: Any, cls: type[Account]) -> None: if attr_relationship is None: raise AttributeError( f'{cls.__name__} does not have a relationship named' - f' {attr_name!r} targeting a subclass of {expected_class.__name__}' + f' {attr_name!r} targeting a subclass of {expected_class.__name__}', + name=attr_name, + obj=cls, ) if not issubclass(attr_relationship.property.mapper.class_, expected_class): raise AttributeError( f'{cls.__name__}.{attr_name} should be a relationship to a' - f' subclass of {expected_class.__name__}' + f' subclass of {expected_class.__name__}', + name=attr_name, + obj=cls, ) diff --git a/funnel/models/moderation.py b/funnel/models/moderation.py index 52b2e73f6..95a388603 100644 --- a/funnel/models/moderation.py +++ b/funnel/models/moderation.py @@ -9,10 +9,9 @@ from coaster.sqlalchemy import StateManager, with_roles from coaster.utils import LabeledEnum -from . import BaseMixin, Mapped, Model, UuidMixin, backref, db, relationship, sa +from . import BaseMixin, Mapped, Model, UuidMixin, db, relationship, sa, sa_orm from .account import Account from .comment import Comment -from .helpers import reopen from .site_membership import SiteMembership __all__ = ['MODERATOR_REPORT_TYPE', 'CommentModeratorReport'] @@ -26,32 +25,24 @@ class MODERATOR_REPORT_TYPE(LabeledEnum): # noqa: N801 class CommentModeratorReport(UuidMixin, BaseMixin[UUID], Model): __tablename__ = 'comment_moderator_report' - comment_id: Mapped[int] = sa.orm.mapped_column( + comment_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('comment.id'), nullable=False, index=True ) - comment: Mapped[Comment] = relationship( - Comment, - foreign_keys=[comment_id], - backref=backref('moderator_reports', cascade='all', lazy='dynamic'), - ) - reported_by_id: Mapped[int] = sa.orm.mapped_column( + comment: Mapped[Comment] = relationship(back_populates='moderator_reports') + reported_by_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False, index=True ) - reported_by: Mapped[Account] = relationship( - Account, - foreign_keys=[reported_by_id], - backref=backref('moderator_reports', cascade='all', lazy='dynamic'), - ) - report_type: Mapped[int] = sa.orm.mapped_column( + reported_by: Mapped[Account] = relationship(back_populates='moderator_reports') + report_type: Mapped[int] = sa_orm.mapped_column( sa.SmallInteger, StateManager.check_constraint('report_type', MODERATOR_REPORT_TYPE), nullable=False, default=MODERATOR_REPORT_TYPE.SPAM, ) - reported_at: Mapped[datetime] = sa.orm.mapped_column( + reported_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), default=sa.func.utcnow(), nullable=False ) - resolved_at: Mapped[datetime | None] = sa.orm.mapped_column( + resolved_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True ) @@ -113,17 +104,3 @@ def users_who_are_comment_moderators(self): ) with_roles(users_who_are_comment_moderators, grants={'comment_moderator'}) - - -@reopen(Comment) -class __Comment: - def is_reviewed_by(self, account: Account) -> bool: - return db.session.query( - db.session.query(CommentModeratorReport) - .filter( - CommentModeratorReport.comment == self, - CommentModeratorReport.resolved_at.is_(None), - CommentModeratorReport.reported_by == account, - ) - .exists() - ).scalar() diff --git a/funnel/models/notification.py b/funnel/models/notification.py index 629c99e04..145dea0de 100644 --- a/funnel/models/notification.py +++ b/funnel/models/notification.py @@ -91,6 +91,7 @@ Generic, Optional, Protocol, + Self, TypeVar, Union, cast, @@ -100,8 +101,7 @@ from uuid import UUID, uuid4 from sqlalchemy import event -from sqlalchemy.orm import column_keyed_dict -from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.exc import NoResultFound from typing_extensions import get_original_bases from werkzeug.utils import cached_property @@ -123,15 +123,14 @@ Model, NoIdMixin, Query, - backref, db, hybrid_property, postgresql, relationship, sa, + sa_orm, ) from .account import Account, AccountEmail, AccountPhone -from .helpers import reopen from .phone_number import PhoneNumber, PhoneNumberMixin from .typing import UuidModelUnion @@ -188,17 +187,15 @@ class NotificationCategory: __("Projects I am participating in"), # Criteria: User has registered or proposed lambda user: ( - db.session.query(user.rsvps.exists()).scalar() # type: ignore[has-type] - or db.session.query( # type: ignore[has-type] - user.proposal_memberships.exists() - ).scalar() + db.session.query(user.rsvps.exists()).scalar() + or db.session.query(user.proposal_memberships.exists()).scalar() ), ), project_crew=NotificationCategory( 4, __("Projects I am a crew member in"), # Criteria: user has ever been a project crew member - lambda user: db.session.query( # type: ignore[has-type] + lambda user: db.session.query( user.projects_as_crew_memberships.exists() ).scalar(), ), @@ -206,7 +203,7 @@ class NotificationCategory: 5, __("Accounts I manage"), # Criteria: user has ever been an organization admin - lambda user: db.session.query( # type: ignore[has-type] + lambda user: db.session.query( user.organization_admin_memberships.exists() ).scalar(), ), @@ -239,26 +236,25 @@ class SmsMessage(PhoneNumberMixin, BaseMixin, Model): """An outbound SMS message.""" __tablename__ = 'sms_message' - __phone_optional__ = False __phone_unique__ = False __phone_is_exclusive__ = False 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.UnicodeText, unique=True, nullable=True) ) # The message itself message: Mapped[str] = immutable( - sa.orm.mapped_column(sa.UnicodeText, nullable=False) + sa_orm.mapped_column(sa.UnicodeText, nullable=False) ) # Flags - status: Mapped[int] = sa.orm.mapped_column( + status: Mapped[int] = sa_orm.mapped_column( sa.Integer, default=SMS_STATUS.QUEUED, nullable=False ) - status_at: Mapped[datetime | None] = sa.orm.mapped_column( + status_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) - fail_reason: Mapped[str | None] = sa.orm.mapped_column( + fail_reason: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True ) @@ -309,14 +305,14 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): #: be shared across notifications, and will be used to enforce a limit of one #: instance of a UserNotification per-event rather than per-notification eventid: Mapped[UUID] = immutable( - sa.orm.mapped_column( + sa_orm.mapped_column( postgresql.UUID, primary_key=True, nullable=False, default=uuid4 ) ) #: Notification id id: Mapped[UUID] = immutable( # noqa: A003 - sa.orm.mapped_column( + sa_orm.mapped_column( postgresql.UUID, primary_key=True, nullable=False, default=uuid4 ) ) @@ -360,28 +356,32 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): #: Notification type (identifier for subclass of :class:`NotificationType`) type_: Mapped[str] = immutable( - sa.orm.mapped_column('type', sa.Unicode, nullable=False) + sa_orm.mapped_column('type', sa.Unicode, nullable=False) ) #: Id of user that triggered this notification - created_by_id: Mapped[int | None] = sa.orm.mapped_column( + created_by_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=True ) #: User that triggered this notification. Optional, as not all notifications are #: caused by user activity. Used to optionally exclude user from receiving #: notifications of their own activity - created_by: Mapped[Account | None] = relationship(Account) + created_by: Mapped[Account | None] = relationship() #: UUID of document that the notification refers to document_uuid: Mapped[UUID] = immutable( - sa.orm.mapped_column(postgresql.UUID, nullable=False, index=True) + sa_orm.mapped_column(postgresql.UUID, nullable=False, index=True) ) #: Optional fragment within document that the notification refers to. This may be #: the document itself, or something within it, such as a comment. Notifications for #: multiple fragments are collapsed into a single notification fragment_uuid: Mapped[UUID | None] = immutable( - sa.orm.mapped_column(postgresql.UUID, nullable=True) + sa_orm.mapped_column(postgresql.UUID, nullable=True) + ) + + recipients: DynamicMapped[NotificationRecipient] = relationship( + lazy='dynamic', back_populates='notification' ) __table_args__ = ( @@ -553,7 +553,7 @@ def __init__( # pylint: disable=isinstance-second-argument-not-valid-type if not isinstance(fragment, self.fragment_model): raise TypeError(f"{fragment!r} is not of type {self.fragment_model!r}") - kwargs['fragment_uuid'] = fragment.uuid + kwargs['fragment_uuid'] = fragment.uuid # type: ignore[union-attr] super().__init__(**kwargs) @property @@ -694,7 +694,7 @@ def dispatch(self) -> Generator[NotificationRecipient, None, 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_') # noqa: A003 class PreviewNotification(NotificationType): @@ -801,7 +801,7 @@ class NotificationRecipient(NoIdMixin, NotificationRecipientProtoMixin, Model): #: Id of user being notified recipient_id: Mapped[int] = immutable( - sa.orm.mapped_column( + sa_orm.mapped_column( sa.Integer, sa.ForeignKey('account.id', ondelete='CASCADE'), primary_key=True, @@ -809,27 +809,27 @@ class NotificationRecipient(NoIdMixin, NotificationRecipientProtoMixin, Model): ) ) - #: User being notified (backref defined below, outside the model) + #: User being notified recipient: Mapped[Account] = with_roles( - relationship(Account), read={'owner'}, grants={'owner'} + relationship(), read={'owner'}, grants={'owner'} ) #: Random eventid, shared with the Notification instance eventid: Mapped[UUID] = with_roles( immutable( - sa.orm.mapped_column(postgresql.UUID, primary_key=True, nullable=False) + sa_orm.mapped_column(postgresql.UUID, primary_key=True, nullable=False) ), read={'owner'}, ) #: Id of notification that this user received (fkey in __table_args__ below) - notification_id: Mapped[UUID] = sa.orm.mapped_column( + notification_id: Mapped[UUID] = sa_orm.mapped_column( postgresql.UUID, nullable=False ) #: Notification that this user received notification: Mapped[Notification] = with_roles( - relationship(Notification, backref=backref('recipients', lazy='dynamic')), + relationship(back_populates='recipients'), read={'owner'}, ) @@ -840,12 +840,12 @@ class NotificationRecipient(NoIdMixin, NotificationRecipientProtoMixin, Model): #: entirely in-app symbol (i.e., code refactorable) to being data in the database #: (i.e., requiring a data migration alongside a code refactor) role: Mapped[str] = with_roles( - immutable(sa.orm.mapped_column(sa.Unicode, nullable=False)), read={'owner'} + immutable(sa_orm.mapped_column(sa.Unicode, nullable=False)), read={'owner'} ) #: Timestamp for when this notification was marked as read read_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), default=None, nullable=True), + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), default=None, nullable=True), read={'owner'}, ) @@ -855,32 +855,32 @@ class NotificationRecipient(NoIdMixin, NotificationRecipientProtoMixin, Model): #: a recipient of the new notification #: 3. The underlying document or fragment has been deleted revoked_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'owner'}, ) #: When a roll-up is performed, record an identifier for the items rolled up rollupid: Mapped[UUID | None] = with_roles( - sa.orm.mapped_column(postgresql.UUID, nullable=True, index=True), + sa_orm.mapped_column(postgresql.UUID, nullable=True, index=True), read={'owner'}, ) #: Message id for email delivery - messageid_email: Mapped[str | None] = sa.orm.mapped_column( + messageid_email: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode, nullable=True ) #: Message id for SMS delivery - messageid_sms: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode, nullable=True) + messageid_sms: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode, nullable=True) #: Message id for web push delivery - messageid_webpush: Mapped[str | None] = sa.orm.mapped_column( + messageid_webpush: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode, nullable=True ) #: Message id for Telegram delivery - messageid_telegram: Mapped[str | None] = sa.orm.mapped_column( + messageid_telegram: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode, nullable=True ) #: Message id for WhatsApp delivery - messageid_whatsapp: Mapped[str | None] = sa.orm.mapped_column( + messageid_whatsapp: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode, nullable=True ) @@ -1122,7 +1122,7 @@ def rollup_previous(self) -> None: NotificationRecipient.rollupid == self.rollupid, ) .options( - sa.orm.load_only( + sa_orm.load_only( NotificationRecipient.recipient_id, NotificationRecipient.eventid, NotificationRecipient.revoked_at, @@ -1135,7 +1135,7 @@ def rollup_previous(self) -> None: def rolledup_fragments(self) -> Query | None: """Return all fragments in the rolled up batch as a base query.""" - if not self.notification.fragment_model: + if self.notification.fragment_model is None: return None # Return a query on the fragment model with the rolled up identifiers if not self.rollupid: @@ -1159,12 +1159,12 @@ def get_for(cls, user: Account, eventid_b58: str) -> NotificationRecipient | Non @classmethod def web_notifications_for( cls, user: Account, unread_only: bool = False - ) -> Query[NotificationRecipient]: + ) -> Query[Self]: """Return web notifications for a user, optionally returning unread-only.""" - query = NotificationRecipient.query.join(Notification).filter( + query = cls.query.join(Notification).filter( Notification.type.in_(notification_web_types), - NotificationRecipient.recipient == user, - NotificationRecipient.revoked_at.is_(None), + cls.recipient == user, + cls.revoked_at.is_(None), ) if unread_only: query = query.filter(NotificationRecipient.read_at.is_(None)) @@ -1251,7 +1251,7 @@ class NotificationPreferences(BaseMixin, Model): __tablename__ = 'notification_preferences' #: Id of account whose preferences are represented here - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('account.id', ondelete='CASCADE'), nullable=False, @@ -1259,7 +1259,7 @@ class NotificationPreferences(BaseMixin, Model): ) #: User account whose preferences are represented here account: Mapped[Account] = with_roles( - relationship(Account, back_populates='notification_preferences'), + relationship(back_populates='notification_preferences'), read={'owner'}, grants={'owner'}, ) @@ -1267,23 +1267,23 @@ class NotificationPreferences(BaseMixin, Model): # Notification type, corresponding to Notification.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(sa.Unicode, nullable=False) + sa_orm.mapped_column(sa.Unicode, nullable=False) ) by_email: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + sa_orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} ) by_sms: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + sa_orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} ) by_webpush: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + sa_orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} ) by_telegram: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + sa_orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} ) by_whatsapp: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} + sa_orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} ) __table_args__ = (sa.UniqueConstraint('account_id', 'notification_type'),) @@ -1378,7 +1378,7 @@ def migrate_account(cls, old_account: Account, new_account: Account) -> None: {'account_id': new_account.id}, synchronize_session=False ) - @sa.orm.validates('notification_type') + @sa_orm.validates('notification_type') def _valid_notification_type(self, key: str, value: str | None) -> str: if value == '': # Special-cased name for main preferences return value @@ -1387,53 +1387,6 @@ def _valid_notification_type(self, key: str, value: str | None) -> str: return value -@reopen(Account) -class __Account: - all_notifications: DynamicMapped[NotificationRecipient] = with_roles( - relationship( - NotificationRecipient, - lazy='dynamic', - order_by=NotificationRecipient.created_at.desc(), - viewonly=True, - ), - read={'owner'}, - ) - - notification_preferences: Mapped[dict[str, NotificationPreferences]] = relationship( - NotificationPreferences, - collection_class=column_keyed_dict(NotificationPreferences.notification_type), - back_populates='account', - ) - - # This relationship is wrapped in a property that creates it on first access - _main_notification_preferences: Mapped[NotificationPreferences] = relationship( - NotificationPreferences, - primaryjoin=sa.and_( - NotificationPreferences.account_id == Account.id, - NotificationPreferences.notification_type == '', - ), - uselist=False, - viewonly=True, - ) - - @cached_property - def main_notification_preferences(self) -> NotificationPreferences: - """Return user's main notification preferences, toggling transports on/off.""" - if not self._main_notification_preferences: - main = NotificationPreferences( - notification_type='', - account=self, - by_email=True, - by_sms=True, - by_webpush=False, - by_telegram=False, - by_whatsapp=False, - ) - db.session.add(main) - return main - return self._main_notification_preferences - - # --- Signal handlers ------------------------------------------------------------------ diff --git a/funnel/models/phone_number.py b/funnel/models/phone_number.py index 72d002a86..2eacfb36e 100644 --- a/funnel/models/phone_number.py +++ b/funnel/models/phone_number.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import warnings from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload @@ -14,7 +15,7 @@ from werkzeug.utils import cached_property from baseframe import _ -from coaster.sqlalchemy import immutable, with_roles +from coaster.sqlalchemy import ModelWarning, immutable, with_roles from coaster.utils import require_one_of from ..signals import phonenumber_refcount_dropping @@ -28,6 +29,7 @@ hybrid_property, relationship, sa, + sa_orm, ) __all__ = [ @@ -40,6 +42,7 @@ 'canonical_phone_number', 'phone_blake2b160_hash', 'PhoneNumber', + 'OptionalPhoneNumberMixin', 'PhoneNumberMixin', ] @@ -255,20 +258,20 @@ class PhoneNumber(BaseMixin, Model): #: Contains the name of the relationship in the :class:`PhoneNumber` model __backrefs__: ClassVar[set[str]] = set() #: These backrefs claim exclusive use of the phone number for their linked owner. - #: See :class:`PhoneNumberMixin` for implementation detail + #: See :class:`OptionalPhoneNumberMixin` for implementation detail __exclusive_backrefs__: ClassVar[set[str]] = set() #: The phone number, centrepiece of this model. Stored normalized in E164 format. #: Validated by the :func:`_validate_phone` event handler - number: Mapped[str | None] = sa.orm.mapped_column( + number: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode, nullable=True, unique=True ) #: BLAKE2b 160-bit hash of :attr:`phone`. Kept permanently even if phone is #: removed. SQLAlchemy type LargeBinary maps to PostgreSQL BYTEA. Despite the name, #: we're only storing 20 bytes - blake2b160 = immutable( - sa.orm.mapped_column( + blake2b160: Mapped[bytes] = immutable( + sa_orm.mapped_column( sa.LargeBinary, sa.CheckConstraint( 'LENGTH(blake2b160) = 20', @@ -284,54 +287,58 @@ class PhoneNumber(BaseMixin, Model): # device, we record distinct timestamps for last sent, delivery and failure. #: Cached state for whether this phone number is known to have SMS support - has_sms: Mapped[bool | None] = sa.orm.mapped_column(sa.Boolean, nullable=True) + has_sms: Mapped[bool | None] = sa_orm.mapped_column(sa.Boolean, nullable=True) #: Timestamp at which this number was determined to be valid/invalid for SMS - has_sms_at: Mapped[datetime | None] = sa.orm.mapped_column( + has_sms_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Cached state for whether this phone number is known to be on WhatsApp or not - has_wa: Mapped[bool | None] = sa.orm.mapped_column(sa.Boolean, nullable=True) + has_wa: Mapped[bool | None] = sa_orm.mapped_column(sa.Boolean, nullable=True) #: Timestamp at which this number was tested for availability on WhatsApp - has_wa_at: Mapped[datetime | None] = sa.orm.mapped_column( + has_wa_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of last SMS sent - msg_sms_sent_at: Mapped[datetime | None] = sa.orm.mapped_column( + msg_sms_sent_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of last SMS delivered - msg_sms_delivered_at: Mapped[datetime | None] = sa.orm.mapped_column( + msg_sms_delivered_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of last SMS delivery failure - msg_sms_failed_at: Mapped[datetime | None] = sa.orm.mapped_column( + msg_sms_failed_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of last WA message sent - msg_wa_sent_at: Mapped[datetime | None] = sa.orm.mapped_column( + msg_wa_sent_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of last WA message delivered - msg_wa_delivered_at: Mapped[datetime | None] = sa.orm.mapped_column( + msg_wa_delivered_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of last WA message delivery failure - msg_wa_failed_at: Mapped[datetime | None] = sa.orm.mapped_column( + msg_wa_failed_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of last known recipient activity resulting from sent messages - active_at: Mapped[datetime | None] = sa.orm.mapped_column( + active_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Is this phone number blocked from being used? :attr:`phone` should be null if so. - blocked_at: Mapped[datetime | None] = sa.orm.mapped_column( + blocked_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) + if TYPE_CHECKING: + used_in_sms_message: Mapped[list[SmsMessage]] = relationship() + used_in_account_phone: Mapped[list[AccountPhone]] = relationship() + __table_args__ = ( # If `blocked_at` is not None, `number` and `has_*` must be None sa.CheckConstraint( @@ -422,7 +429,7 @@ def md5(self) -> str | None: @cached_property def parsed(self) -> phonenumbers.PhoneNumber | None: - """Return parsed phone number using libphonenumbers.""" + """Return parsed phone number using libphonenumber.""" if self.number: return phonenumbers.parse(self.number) return None @@ -548,7 +555,7 @@ def get_filter( phone_hash: str | None = None, ) -> ColumnElement[bool]: """ - Get an filter condition for retriving a :class:`PhoneNumber`. + Get an filter condition for retrieving a :class:`PhoneNumber`. Accepts a normalized phone number or a blake2b160 hash in either bytes or base58 form. Internally converts all lookups to a bytes-based hash lookup. Returns an @@ -706,7 +713,7 @@ def validate_for( # There's an existing? Is it blocked? if existing.is_blocked: return 'blocked' - # Is the existing phone mumber available for this owner? + # Is the existing phone number available for this owner? if not existing.is_available_for(owner): # Not available, so return False return 'taken' @@ -722,17 +729,19 @@ def get_numbers(cls, prefix: str, remove: bool = True) -> set[str]: """Get all numbers with the given prefix as a Python set.""" query = ( cls.query.filter(cls.number.startswith(prefix)) - .options(sa.orm.load_only(cls.number)) + .options(sa_orm.load_only(cls.number)) .yield_per(1000) ) + # This query only has results where `.number` is not None, so we type checkers + # have to be told to ignore the possibility of a null: if remove: skip = len(prefix) - return {r.number[skip:] for r in query} - return {r.number for r in query} + return {r.number[skip:] for r in query} # type: ignore[index] + return {r.number for r in query} # type: ignore[misc] @declarative_mixin -class PhoneNumberMixin: +class OptionalPhoneNumberMixin: """ Mixin class for models that refer to :class:`PhoneNumber`. @@ -756,9 +765,9 @@ class PhoneNumberMixin: @declared_attr @classmethod - def phone_number_id(cls) -> Mapped[int]: + def phone_number_id(cls) -> Mapped[int | None]: """Foreign key to phone_number table.""" - return sa.orm.mapped_column( + return sa_orm.mapped_column( sa.Integer, sa.ForeignKey('phone_number.id', ondelete='SET NULL'), nullable=cls.__phone_optional__, @@ -768,13 +777,14 @@ def phone_number_id(cls) -> Mapped[int]: @declared_attr @classmethod - def phone_number(cls) -> Mapped[PhoneNumber]: + def phone_number(cls) -> Mapped[PhoneNumber | None]: """Instance of :class:`PhoneNumber` as a relationship.""" backref_name = 'used_in_' + cls.__tablename__ PhoneNumber.__backrefs__.add(backref_name) if cls.__phone_for__ and cls.__phone_is_exclusive__: PhoneNumber.__exclusive_backrefs__.add(backref_name) - return relationship(PhoneNumber, backref=backref_name) + with warnings.catch_warnings(action='ignore', category=ModelWarning): + return relationship(PhoneNumber, backref=backref_name) @property def phone(self) -> str | None: @@ -794,17 +804,17 @@ def phone(self) -> str | None: return None @phone.setter - def phone(self, value: str | None) -> None: + def phone(self, __value: str | None) -> None: if self.__phone_for__: - if value is not None: + if __value is not None: self.phone_number = PhoneNumber.add_for( - getattr(self, self.__phone_for__), value + getattr(self, self.__phone_for__), __value ) else: self.phone_number = None else: - if value is not None: - self.phone_number = PhoneNumber.add(value) + if __value is not None: + self.phone_number = PhoneNumber.add(__value) else: self.phone_number = None @@ -828,6 +838,37 @@ def transport_hash(self) -> str | None: ) +@declarative_mixin +class PhoneNumberMixin(OptionalPhoneNumberMixin): + """Non-optional version of :class:`OptionalPhoneNumberMixin`.""" + + __phone_optional__: ClassVar[bool] = False + + if TYPE_CHECKING: + + @declared_attr + @classmethod + def phone_number_id(cls) -> Mapped[int]: # type: ignore[override] + ... + + @declared_attr + @classmethod + def phone_number(cls) -> Mapped[PhoneNumber]: # type: ignore[override] + ... + + @property # type: ignore[override] + def phone(self) -> str: + ... + + @phone.setter + def phone(self, __value: str) -> None: + ... + + @property + def transport_hash(self) -> str: + ... + + def _clear_cached_properties(target: PhoneNumber) -> None: """Clear cached properties in :class:`PhoneNumber`.""" for attr in ('parsed', 'formatted'): @@ -884,7 +925,7 @@ def _send_refcount_event_remove( def _send_refcount_event_before_delete( - _mapper: Any, _connection: Any, target: PhoneNumberMixin + _mapper: Any, _connection: Any, target: OptionalPhoneNumberMixin ) -> None: if target.phone_number: phonenumber_refcount_dropping.send(target.phone_number) @@ -898,7 +939,7 @@ def _setup_refcount_events() -> None: def _phone_number_mixin_set_validator( - target: PhoneNumberMixin, + target: OptionalPhoneNumberMixin, value: PhoneNumber | None, old_value: PhoneNumber | None, _initiator: Any, @@ -910,13 +951,14 @@ def _phone_number_mixin_set_validator( raise PhoneNumberInUseError("This phone number it not available") -@event.listens_for(PhoneNumberMixin, 'mapper_configured', propagate=True) +@event.listens_for(OptionalPhoneNumberMixin, 'mapper_configured', propagate=True) def _phone_number_mixin_configure_events( - _mapper: Any, cls: type[PhoneNumberMixin] + _mapper: Any, cls: type[OptionalPhoneNumberMixin] ) -> None: event.listen(cls.phone_number, 'set', _phone_number_mixin_set_validator) event.listen(cls, 'before_delete', _send_refcount_event_before_delete) if TYPE_CHECKING: - from .account import Account + from .account import Account, AccountPhone + from .notification import SmsMessage diff --git a/funnel/models/project.py b/funnel/models/project.py index a1a7f92ac..a77422729 100644 --- a/funnel/models/project.py +++ b/funnel/models/project.py @@ -1,17 +1,27 @@ """Project model.""" +# pylint: disable=unnecessary-lambda from __future__ import annotations +from collections import OrderedDict, defaultdict from collections.abc import Sequence -from datetime import datetime +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Literal, Self, cast, overload +from flask_babel import format_date, get_locale from furl import furl +from isoweek import Week from pytz import BaseTzInfo, utc -from sqlalchemy.orm import attribute_keyed_dict +from sqlalchemy.ext.orderinglist import OrderingList, ordering_list from werkzeug.utils import cached_property from baseframe import __, localize_timezone -from coaster.sqlalchemy import LazyRoleSet, StateManager, with_roles +from coaster.sqlalchemy import ( + DynamicAssociationProxy, + LazyRoleSet, + StateManager, + with_roles, +) from coaster.utils import LabeledEnum, buid, utcnow from .. import app @@ -26,11 +36,11 @@ TSVectorType, UrlType, UuidMixin, - backref, db, hybrid_property, relationship, sa, + sa_orm, types, ) from .account import Account @@ -40,7 +50,6 @@ ImgeeType, MarkdownCompositeDocument, add_search_trigger, - reopen, valid_name, visual_field_delimiter, ) @@ -80,22 +89,15 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): __tablename__ = 'project' reserved_names = RESERVED_NAMES - created_by_id: Mapped[int] = sa.orm.mapped_column( + created_by_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) - created_by: Mapped[Account] = relationship( - Account, - foreign_keys=[created_by_id], - ) - account_id: Mapped[int] = sa.orm.mapped_column( + created_by: Mapped[Account] = relationship(foreign_keys=[created_by_id]) + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) account: Mapped[Account] = with_roles( - relationship( - Account, - foreign_keys=[account_id], - backref=backref('projects', cascade='all', lazy='dynamic'), - ), + relationship(foreign_keys=[account_id], back_populates='projects'), read={'all'}, # If account grants an 'admin' role, make it 'account_admin' here grants_via={ @@ -109,9 +111,9 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): # 'related' or 'without_parent' as it is the parent datasets={'primary'}, ) - parent: Mapped[Account] = sa.orm.synonym('account') + parent: Mapped[Account] = sa_orm.synonym('account') tagline: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.Unicode(250), nullable=False), + sa_orm.mapped_column(sa.Unicode(250), nullable=False), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) @@ -127,24 +129,24 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): with_roles(instructions, read={'all'}) location: Mapped[str | None] = with_roles( - sa.orm.mapped_column(sa.Unicode(50), default='', nullable=True), + sa_orm.mapped_column(sa.Unicode(50), default='', nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) parsed_location: Mapped[types.jsonb_dict] website: Mapped[furl | None] = with_roles( - sa.orm.mapped_column(UrlType, nullable=True), + sa_orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) timezone: Mapped[BaseTzInfo] = with_roles( - sa.orm.mapped_column(TimezoneType(backend='pytz'), nullable=False, default=utc), + sa_orm.mapped_column(TimezoneType(backend='pytz'), nullable=False, default=utc), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) - _state: Mapped[int] = sa.orm.mapped_column( + _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.Integer, StateManager.check_constraint('state', PROJECT_STATE), @@ -153,9 +155,10 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): index=True, ) state = with_roles( - StateManager('_state', PROJECT_STATE, doc="Project state"), call={'all'} + StateManager['Project']('_state', PROJECT_STATE, doc="Project state"), + call={'all'}, ) - _cfp_state: Mapped[int] = sa.orm.mapped_column( + _cfp_state: Mapped[int] = sa_orm.mapped_column( 'cfp_state', sa.Integer, StateManager.check_constraint('cfp_state', CFP_STATE), @@ -164,12 +167,12 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): index=True, ) cfp_state = with_roles( - StateManager('_cfp_state', CFP_STATE, doc="CfP state"), call={'all'} + StateManager['Project']('_cfp_state', CFP_STATE, doc="CfP state"), call={'all'} ) #: State of RSVPs rsvp_state: Mapped[int] = with_roles( - sa.orm.mapped_column( + sa_orm.mapped_column( sa.SmallInteger, StateManager.check_constraint('rsvp_state', PROJECT_RSVP_STATE), default=PROJECT_RSVP_STATE.NONE, @@ -181,62 +184,62 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): ) #: Audit timestamp to detect re-publishing to re-surface a project - first_published_at: Mapped[datetime | None] = sa.orm.mapped_column( + first_published_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of when this project was most recently published published_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'all'}, write={'promoter'}, datasets={'primary', 'without_parent', 'related'}, ) #: Optional start time for schedule, cached from column property schedule_start_at start_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'all'}, write={'editor'}, datasets={'primary', 'without_parent', 'related'}, ) #: Optional end time for schedule, cached from column property schedule_end_at end_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'all'}, write={'editor'}, datasets={'primary', 'without_parent', 'related'}, ) - cfp_start_at: Mapped[datetime | None] = sa.orm.mapped_column( + cfp_start_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True ) - cfp_end_at: Mapped[datetime | None] = sa.orm.mapped_column( + cfp_end_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True ) bg_image: Mapped[furl | None] = with_roles( - sa.orm.mapped_column(ImgeeType, nullable=True), + sa_orm.mapped_column(ImgeeType, nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) #: Auto-generated preview image for Open Graph - preview_image: Mapped[bytes | None] = sa.orm.mapped_column( + preview_image: Mapped[bytes | None] = sa_orm.mapped_column( sa.LargeBinary, nullable=True, deferred=True ) buy_tickets_url: Mapped[furl | None] = with_roles( - sa.orm.mapped_column(UrlType, nullable=True), + sa_orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) banner_video_url: Mapped[furl | None] = with_roles( - sa.orm.mapped_column(UrlType, nullable=True), + sa_orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) boxoffice_data: Mapped[types.jsonb_dict] = with_roles( - sa.orm.mapped_column(), + sa_orm.mapped_column(), # This is an attribute, but we deliberately use `call` instead of `read` to # block this from dictionary enumeration. FIXME: Break up this dictionary into # individual columns with `all` access for ticket embed id and `promoter` @@ -245,25 +248,23 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): ) hasjob_embed_url: Mapped[furl | None] = with_roles( - sa.orm.mapped_column(UrlType, nullable=True), read={'all'} + sa_orm.mapped_column(UrlType, nullable=True), read={'all'} ) hasjob_embed_limit: Mapped[int | None] = with_roles( - sa.orm.mapped_column(sa.Integer, default=8, nullable=True), read={'all'} + sa_orm.mapped_column(sa.Integer, default=8, nullable=True), read={'all'} ) - commentset_id: Mapped[int] = sa.orm.mapped_column( + commentset_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('commentset.id'), nullable=False ) commentset: Mapped[Commentset] = relationship( - Commentset, - uselist=False, - cascade='all', - single_parent=True, - back_populates='project', + uselist=False, single_parent=True, back_populates='project' ) - parent_id: Mapped[int | None] = sa.orm.mapped_column( - sa.ForeignKey('project.id', ondelete='SET NULL'), nullable=True + parent_project_id: Mapped[int | None] = sa_orm.mapped_column( + 'parent_id', # TODO: Migration required + sa.ForeignKey('project.id', ondelete='SET NULL'), + nullable=True, ) parent_project: Mapped[Project | None] = relationship( remote_side='Project.id', back_populates='subprojects' @@ -273,14 +274,14 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): #: Featured project flag. This can only be set by website editors, not #: project editors or account admins. site_featured: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, default=False, nullable=False), + sa_orm.mapped_column(sa.Boolean, default=False, nullable=False), read={'all'}, write={'site_editor'}, datasets={'primary', 'without_parent'}, ) livestream_urls: Mapped[list[str] | None] = with_roles( - sa.orm.mapped_column( + sa_orm.mapped_column( sa.ARRAY(sa.UnicodeText, dimensions=1), nullable=True, # For legacy data server_default=sa.text("'{}'::text[]"), @@ -290,17 +291,17 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): ) is_restricted_video: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, default=False, nullable=False), + sa_orm.mapped_column(sa.Boolean, default=False, nullable=False), read={'all'}, datasets={'primary', 'without_parent'}, ) #: Revision number maintained by SQLAlchemy, used for vCal files, starting at 1 revisionid: Mapped[int] = with_roles( - sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + sa_orm.mapped_column(sa.Integer, nullable=False), read={'all'} ) - search_vector: Mapped[str] = sa.orm.mapped_column( + search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( 'name', 'title', @@ -327,8 +328,168 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): deferred=True, ) - # Relationships - primary_venue: Mapped[Venue | None] = relationship() + # --- Backrefs and relationships + + redirects: Mapped[list[ProjectRedirect]] = relationship(back_populates='project') + locations: Mapped[list[ProjectLocation]] = relationship(back_populates='project') + + # label.py + labels: Mapped[list[Label]] = relationship( + primaryjoin=lambda: sa.and_( + Label.project_id == Project.id, + Label.main_label_id.is_(None), + Label._archived.is_(False), # pylint: disable=protected-access + ), + order_by=lambda: Label.seq, + viewonly=True, + ) + all_labels: Mapped[OrderingList[Label]] = relationship( + collection_class=ordering_list('seq', count_from=1), + back_populates='project', + ) + + # project_membership.py + crew_memberships: DynamicMapped[ProjectMembership] = relationship( + lazy='dynamic', passive_deletes=True, back_populates='project' + ) + active_crew_memberships: DynamicMapped[ProjectMembership] = with_roles( + relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, + ), + viewonly=True, + ), + grants_via={'member': {'editor', 'promoter', 'usher', 'participant', 'crew'}}, + ) + + active_editor_memberships: DynamicMapped[ProjectMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, + ProjectMembership.is_editor.is_(True), + ), + viewonly=True, + ) + + active_promoter_memberships: DynamicMapped[ProjectMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, + ProjectMembership.is_promoter.is_(True), + ), + viewonly=True, + ) + + active_usher_memberships: DynamicMapped[ProjectMembership] = relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectMembership.project_id == Project.id, + ProjectMembership.is_active, + ProjectMembership.is_usher.is_(True), + ), + viewonly=True, + ) + + crew = DynamicAssociationProxy[Account]('active_crew_memberships', 'member') + editors = DynamicAssociationProxy[Account]('active_editor_memberships', 'member') + promoters = DynamicAssociationProxy[Account]( + 'active_promoter_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' + ) + + # rsvp.py + rsvps: DynamicMapped[Rsvp] = relationship(lazy='dynamic', back_populates='project') + + # saved.py + saves: DynamicMapped[SavedProject] = relationship( + lazy='dynamic', passive_deletes=True, back_populates='project' + ) + + # session.py + sessions: DynamicMapped[Session] = relationship( + lazy='dynamic', back_populates='project' + ) + + if TYPE_CHECKING: + # These are column properties, defined at the end of the file + schedule_start_at: Mapped[datetime | None] + next_session_at: Mapped[datetime | None] + schedule_end_at: Mapped[datetime | None] + # This relationship is added by add_primary_relationship in models/venue.py + primary_venue: Mapped[Venue | None] = relationship() + + # sponsor_membership.py + all_sponsor_memberships: DynamicMapped[ProjectSponsorMembership] = relationship( + lazy='dynamic', passive_deletes=True, back_populates='project' + ) + + sponsor_memberships: DynamicMapped[ProjectSponsorMembership] = with_roles( + relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProjectSponsorMembership.project_id == Project.id, + ProjectSponsorMembership.is_active, + ), + order_by=lambda: ProjectSponsorMembership.seq, + viewonly=True, + ), + read={'all'}, + ) + + @with_roles(read={'all'}) + @cached_property + def has_sponsors(self) -> bool: + return db.session.query(self.sponsor_memberships.exists()).scalar() + + sponsors = DynamicAssociationProxy[Account]('sponsor_memberships', 'member') + + # sync_ticket.py + ticket_clients: Mapped[list[TicketClient]] = relationship(back_populates='project') + ticket_events: Mapped[list[TicketEvent]] = relationship(back_populates='project') + ticket_types: Mapped[list[TicketType]] = relationship(back_populates='project') + # XXX: This relationship exposes an edge case in RoleMixin. It previously expected + # TicketParticipant.participant to be unique per project, meaning one user could + # have one participant ticket only. This is not guaranteed by the model as tickets + # are unique per email address per ticket type, and one user can have (a) two email + # addresses with tickets, or (b) tickets of different types. RoleMixin has since + # been patched to look for the first matching record (.first() instead of .one()). + # This may expose a new edge case in future in case the TicketParticipant model adds + # an `offered_roles` method, as only the first matching record's method will be + # called + ticket_participants: DynamicMapped[TicketParticipant] = with_roles( + relationship(lazy='dynamic', back_populates='project'), + grants_via={ + 'participant': {'participant', 'project_participant', 'ticket_participant'} + }, + ) + + # update.py + updates: DynamicMapped[Update] = relationship( + lazy='dynamic', back_populates='project' + ) + + # venue.py + venues: Mapped[OrderingList[Venue]] = with_roles( + relationship( + order_by=lambda: Venue.seq, + collection_class=ordering_list('seq', count_from=1), + back_populates='project', + ), + read={'all'}, + ) + + @property + def rooms(self): + return [room for venue in self.venues for room in venue.rooms] __table_args__ = ( sa.UniqueConstraint('account_id', 'name'), @@ -364,8 +525,8 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): 'short_title', # From BaseScopedNameMixin 'title', # From BaseScopedNameMixin 'urls', # From UrlForMixin - 'created_at', # From TimestampMixin, used for ical render timestamp - 'updated_at', # From TimestampMixin, used for ical render timestamp + 'created_at', # From TimestampMixin, used for vCal render timestamp + 'updated_at', # From TimestampMixin, used for vCal render timestamp }, 'call': { 'features', # From RegistryMixin @@ -408,6 +569,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): state.PUBLISHED, lambda project: ( project.start_at is not None + and project.end_at is not None and project.start_at <= utcnow() < project.end_at ), lambda project: sa.and_( @@ -434,17 +596,13 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): cfp_state.add_conditional_state( 'HAS_PROPOSALS', cfp_state.ANY, - lambda project: db.session.query( # type: ignore[has-type] - project.proposals.exists() - ).scalar(), + lambda project: db.session.query(project.proposals.exists()).scalar(), label=('has_proposals', __("Has submissions")), ) cfp_state.add_conditional_state( 'HAS_SESSIONS', cfp_state.ANY, - lambda project: db.session.query( # type: ignore[has-type] - project.sessions.exists() - ).scalar(), + lambda project: db.session.query(project.sessions.exists()).scalar(), label=('has_sessions', __("Has sessions")), ) cfp_state.add_conditional_state( @@ -511,7 +669,7 @@ def __str__(self) -> str: def __format__(self, format_spec: str) -> str: if not format_spec: return self.joined_title - return self.joined_title.__format__(format_spec) + return format(self.joined_title, format_spec) @with_roles(call={'editor'}) @cfp_state.transition( @@ -684,7 +842,7 @@ def datelocation(self) -> str: # def delete(self): # pass - @sa.orm.validates('name', 'account') + @sa_orm.validates('name', 'account') def _validate_and_create_redirect(self, key: str, value: str | None) -> str: # TODO: When labels, venues and other resources are relocated from project to # account, this validator can no longer watch for `account` change. We'll need a @@ -739,6 +897,55 @@ def allow_rsvp(self) -> bool: """RSVP state as a boolean value (allowed for all or not).""" return self.rsvp_state == PROJECT_RSVP_STATE.ALL + @property + def active_rsvps(self) -> Query[Rsvp]: + return self.rsvps.join(Account).filter(Rsvp.state.YES, Account.state.ACTIVE) + + @overload + def rsvp_for(self, account: Account, create: Literal[True]) -> Rsvp: + ... + + @overload + def rsvp_for( + self, account: Account | None, create: Literal[False] = False + ) -> Rsvp | None: + ... + + def rsvp_for(self, account: Account | None, create=False) -> Rsvp | None: + return Rsvp.get_for(cast(Project, self), account, create) + + def rsvps_with(self, status: str): + return ( + cast(Project, self) + .rsvps.join(Account) + .filter( + Account.state.ACTIVE, + Rsvp._state == status, # pylint: disable=protected-access + ) + ) + + def rsvp_counts(self) -> dict[str, int]: + 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 + ) + .join(Account) + .filter(Account.state.ACTIVE, Rsvp.project == self) + .group_by(Rsvp._state) # pylint: disable=protected-access + .all() + } + + @cached_property + def rsvp_count_going(self) -> int: + return ( + cast(Project, self) + .rsvps.join(Account) + .filter(Account.state.ACTIVE, Rsvp.state.YES) + .count() + ) + def update_schedule_timestamps(self) -> None: """Update cached timestamps from sessions.""" self.start_at = self.schedule_start_at @@ -756,6 +963,439 @@ def is_safe_to_delete(self) -> bool: """Return True if project has no proposals.""" return self.proposals.count() == 0 + @property + def proposals_all(self): + if self.subprojects: + return Proposal.query.filter( + Proposal.project_id.in_([self.id] + [s.id for s in self.subprojects]) + ) + return self.proposals + + @property + def proposals_by_state(self): + if self.subprojects: + basequery = Proposal.query.filter( + Proposal.project_id.in_([self.id] + [s.id for s in self.subprojects]) + ) + else: + basequery = Proposal.query.filter_by(project=self) + return Proposal.state.group( + basequery.filter( + ~(Proposal.state.DRAFT), ~(Proposal.state.DELETED) + ).order_by(sa.desc('created_at')) + ) + + @property + def proposals_by_confirmation(self): + if self.subprojects: + basequery = Proposal.query.filter( + Proposal.project_id.in_([self.id] + [s.id for s in self.subprojects]) + ) + else: + basequery = Proposal.query.filter_by(project=self) + return { + 'confirmed': basequery.filter(Proposal.state.CONFIRMED) + .order_by(sa.desc('created_at')) + .all(), + 'unconfirmed': basequery.filter( + ~(Proposal.state.CONFIRMED), + ~(Proposal.state.DRAFT), + ~(Proposal.state.DELETED), + ) + .order_by(sa.desc('created_at')) + .all(), + } + + if TYPE_CHECKING: + _has_featured_proposals: Mapped[bool | None] + + @property + def has_featured_proposals(self) -> bool: + return bool(self._has_featured_proposals) + + with_roles(has_featured_proposals, read={'all'}) + + @with_roles(call={'all'}) + def is_saved_by(self, account: Account) -> bool: + return account is not None and self.saves.filter_by(account=account).notempty() + + @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) + @cached_property + def schedule_start_at_localized(self) -> datetime | None: + return ( + localize_timezone(self.schedule_start_at, tz=self.timezone) + if self.schedule_start_at + else None + ) + + @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) + @cached_property + def schedule_end_at_localized(self) -> datetime | None: + return ( + localize_timezone(self.schedule_end_at, tz=self.timezone) + if self.schedule_end_at + else None + ) + + @with_roles(read={'all'}) + @cached_property + def session_count(self) -> int: + return self.sessions.filter(Session.start_at.is_not(None)).count() + + featured_sessions: Mapped[list[Session]] = with_roles( + relationship( + order_by=lambda: Session.start_at.asc(), + primaryjoin=lambda: sa.and_( + Session.project_id == Project.id, Session.featured.is_(True) + ), + viewonly=True, + ), + read={'all'}, + ) + scheduled_sessions: Mapped[list[Session]] = with_roles( + relationship( + order_by=lambda: Session.start_at.asc(), + primaryjoin=lambda: sa.and_( + Session.project_id == Project.id, + Session.scheduled, + ), + viewonly=True, + ), + read={'all'}, + ) + unscheduled_sessions: Mapped[list[Session]] = with_roles( + relationship( + order_by=lambda: Session.start_at.asc(), + primaryjoin=lambda: sa.and_( + Session.project_id == Project.id, + Session.scheduled.is_not(True), + ), + viewonly=True, + ), + read={'all'}, + ) + + sessions_with_video: DynamicMapped[Session] = with_roles( + relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + Project.id == Session.project_id, + Session.video_id.is_not(None), + Session.video_source.is_not(None), + ), + viewonly=True, + ), + read={'all'}, + ) + + @with_roles(read={'all'}) + @cached_property + def has_sessions_with_video(self) -> bool: + return self.query.session.query(self.sessions_with_video.exists()).scalar() + + def next_session_from(self, timestamp: datetime) -> Session | None: + """Find the next session in this project from given timestamp.""" + return ( + self.sessions.filter( + Session.start_at.is_not(None), Session.start_at >= timestamp + ) + .order_by(Session.start_at.asc()) + .first() + ) + + @with_roles(call={'all'}) + def next_starting_at(self, timestamp: datetime | None = None) -> datetime | None: + """ + Return timestamp of next session from given timestamp. + + Supplements :attr:`next_session_at` to also consider projects without sessions. + """ + # If there's no `self.start_at`, there is no session either + if self.start_at is not None: + if timestamp is None: + timestamp = utcnow() + # If `self.start_at` is in the future, it is guaranteed to be the closest + # timestamp, so return it directly + if self.start_at >= timestamp: + return self.start_at + # In the past? Then look for a session and return that timestamp, if any + return ( + db.session.query(sa.func.min(Session.start_at)) + .filter( + Session.start_at.is_not(None), + Session.start_at >= timestamp, + Session.project == self, + ) + .scalar() + ) + + return None + + @classmethod + def starting_at( + cls, timestamp: datetime, within: timedelta, gap: timedelta + ) -> Query[Self]: + """ + Return projects that are about to start, for sending notifications. + + :param datetime timestamp: The timestamp to look for new sessions at + :param timedelta within: Find anything at timestamp + within delta. Lookup will + be for sessions where timestamp >= start_at < timestamp+within + :param timedelta gap: A project will be considered to be starting if it has no + sessions ending within the gap period before the timestamp + + Typical use of this method is from a background worker that calls it at + intervals of five minutes with parameters (timestamp, within 5m, 60m gap). + """ + # As a rule, start_at is queried with >= and <, end_at with > and <= because + # they represent inclusive lower and upper bounds. + + # Check project starting time before looking for individual sessions, as some + # projects will have no sessions + return ( + cls.query.filter( + cls.id.in_( + db.session.query(sa.func.distinct(Session.project_id)).filter( + Session.start_at.is_not(None), + Session.start_at >= timestamp, + Session.start_at < timestamp + within, + Session.project_id.notin_( + db.session.query( + sa.func.distinct(Session.project_id) + ).filter( + Session.start_at.is_not(None), + sa.or_( + sa.and_( + Session.start_at >= timestamp - gap, + Session.start_at < timestamp, + ), + sa.and_( + Session.end_at > timestamp - gap, + Session.end_at <= timestamp, + ), + ), + ) + ), + ) + ) + ) + .join(Session.project) + .filter(cls.state.PUBLISHED) + ).union( + cls.query.filter( + cls.state.PUBLISHED, + cls.start_at.is_not(None), + cls.start_at >= timestamp, + cls.start_at < timestamp + within, + ) + ) + + @with_roles(call={'all'}) + def current_sessions(self) -> dict | None: + if self.start_at is None or (self.start_at > utcnow() + timedelta(minutes=30)): + return None + + current_sessions = ( + self.sessions.outerjoin(VenueRoom) + .filter(Session.start_at <= sa.func.utcnow() + timedelta(minutes=30)) + .filter(Session.end_at > sa.func.utcnow()) + .order_by(Session.start_at.asc(), VenueRoom.seq.asc()) + ) + + return { + 'sessions': [ + session.current_access(datasets=('without_parent', 'related')) + for session in current_sessions + ], + 'rooms': [ + room.current_access(datasets=('without_parent', 'related')) + for room in self.rooms + ], + } + + # TODO: Use TypedDict for return type + def calendar_weeks(self, leading_weeks: bool = True) -> dict[str, Any]: + # session_dates is a list of tuples in this format - + # (date, day_start_at, day_end_at, event_count) + if self.schedule_start_at: + session_dates = list( + db.session.query( + sa.func.date_trunc( + 'day', sa.func.timezone(self.timezone.zone, Session.start_at) + ).label('date'), + sa.func.min(Session.start_at).label('day_start_at'), + sa.func.max(Session.end_at).label('day_end_at'), + sa.func.count().label('count'), + ) + .select_from(Session) + .filter( + Session.project == self, + Session.start_at.is_not(None), + Session.end_at.is_not(None), + ) + .group_by('date') + .order_by('date') + ) + elif self.start_at: + start_at = cast(datetime, self.start_at_localized) + end_at = cast(datetime, self.end_at_localized) + if start_at.date() == end_at.date(): + session_dates = [(start_at, start_at, end_at, 1)] + else: + session_dates = [ + ( + start_at + timedelta(days=plusdays), + start_at + timedelta(days=plusdays), + end_at - timedelta(days=plusdays), + 1, + ) + for plusdays in range( + ( + end_at.replace(hour=1, minute=0, second=0, microsecond=0) + - start_at.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + ).days + + 1 + ) + ] + else: + session_dates = [] + + session_dates_dict = { + date.date(): { + 'day_start_at': day_start_at, + 'day_end_at': day_end_at, + 'count': count, + } + for date, day_start_at, day_end_at, count in session_dates + } + + # FIXME: This doesn't work. This code needs to be tested in isolation + # session_dates = ( + # db.session.query( + # sa.cast( + # sa.func.date_trunc( + # 'day', sa.func.timezone(self.timezone.zone, Session.start_at) + # ), + # sa.Date, + # ).label('date'), + # sa.func.count().label('count'), + # ) + # .filter(Session.project == self, Session.scheduled) + # .group_by(sa.text('date')) + # .order_by(sa.text('date')) + # ) + + # if the project's week is within next 2 weeks, send current week as well + now = utcnow().astimezone(self.timezone) + current_week = Week.withdate(now) + + if leading_weeks and self.schedule_start_at is not None: + schedule_start_week = Week.withdate(self.schedule_start_at) + + # session_dates is a list of tuples in this format - + # (date, day_start_at, day_end_at, event_count) + # as these days dont have any event, day_start/end_at are None, + # and count is 0. + if ( + schedule_start_week > current_week + and (schedule_start_week - current_week) <= 2 + ): + if (schedule_start_week - current_week) == 2: + # add this so that the next week's dates + # are also included in the calendar. + session_dates.insert(0, (now + timedelta(days=7), None, None, 0)) + session_dates.insert(0, (now, None, None, 0)) + + weeks: dict[str, dict[str, Any]] = defaultdict(dict) + today = now.date() + for project_date, _day_start_at, _day_end_at, session_count in session_dates: + weekobj = Week.withdate(project_date) + weekid = weekobj.isoformat() + if weekid not in weeks: + weeks[weekid]['year'] = weekobj.year + # Order is important, and we need dict to count easily + weeks[weekid]['dates'] = OrderedDict() + for wdate in weekobj.days(): + weeks[weekid]['dates'].setdefault(wdate, 0) + if project_date.date() == wdate: + # If the event is over don't set upcoming for current week + if wdate >= today and weekobj >= current_week and session_count > 0: + weeks[weekid]['upcoming'] = True + weeks[weekid]['dates'][wdate] += session_count + if 'month' not in weeks[weekid]: + weeks[weekid]['month'] = format_date(wdate, 'MMM') + # Extract sorted weeks as a list + weeks_list = [v for k, v in sorted(weeks.items())] + + for week in weeks_list: + # Converting to JSON messes up dictionary key order even though we used + # OrderedDict. This turns the OrderedDict into a list of tuples and JSON + # preserves that order. + week['dates'] = [ + { + 'isoformat': date.isoformat(), + 'day': format_date(date, 'd'), + 'count': count, + 'day_start_at': ( + session_dates_dict[date]['day_start_at'] + .astimezone(self.timezone) + .strftime('%I:%M %p') + if date in session_dates_dict.keys() + 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() + else None + ), + } + for date, count in week['dates'].items() + ] + return { + 'locale': get_locale(), + 'weeks': weeks_list, + 'today': now.date().isoformat(), + 'days': [format_date(day, 'EEE') for day in Week.thisweek().days()], + } + + @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) + @cached_property + def calendar_weeks_full(self) -> dict[str, Any]: # TODO: Use TypedDict + return self.calendar_weeks(leading_weeks=True) + + @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) + @cached_property + def calendar_weeks_compact(self) -> dict[str, Any]: # TODO: Use TypedDict + return self.calendar_weeks(leading_weeks=False) + + @property + def published_updates(self) -> Query[Update]: + return self.updates.filter(Update.state.PUBLISHED).order_by( + Update.is_pinned.desc(), Update.published_at.desc() + ) + + with_roles(published_updates, read={'all'}) + + @property + def draft_updates(self) -> Query[Update]: + return self.updates.filter(Update.state.DRAFT).order_by(Update.created_at) + + with_roles(draft_updates, read={'editor'}) + + @property + def pinned_update(self) -> Update | None: + return ( + self.updates.filter(Update.state.PUBLISHED, Update.is_pinned.is_(True)) + .order_by(Update.published_at.desc()) + .first() + ) + + with_roles(pinned_update, read={'all'}) + @classmethod def order_by_date(cls) -> sa.Case: """ @@ -770,7 +1410,7 @@ def order_by_date(cls) -> sa.Case: return clause @classmethod - def all_unsorted(cls) -> Query[Project]: + def all_unsorted(cls) -> Query[Self]: """Return query of all published projects, without ordering criteria.""" return ( cls.query.join(Account, Project.account) @@ -779,7 +1419,7 @@ def all_unsorted(cls) -> Query[Project]: ) @classmethod - def all(cls) -> Query[Project]: # noqa: A003 + def all(cls) -> Query[Self]: # noqa: A003 """Return all published projects, ordered by date.""" return cls.all_unsorted().order_by(cls.order_by_date()) @@ -815,102 +1455,22 @@ def migrate_account(cls, old_account: Account, new_account: Account) -> None: add_search_trigger(Project, 'search_vector') -@reopen(Account) -class __Account: - id: Mapped[int] # noqa: A003 - - listed_projects: DynamicMapped[Project] = relationship( - Project, - lazy='dynamic', - primaryjoin=sa.and_( - Account.id == Project.account_id, - Project.state.PUBLISHED, - ), - viewonly=True, - ) - draft_projects: DynamicMapped[Project] = relationship( - Project, - lazy='dynamic', - primaryjoin=sa.and_( - Account.id == Project.account_id, - sa.or_(Project.state.DRAFT, Project.cfp_state.DRAFT), - ), - viewonly=True, - ) - projects_by_name: Mapped[dict[str, Project]] = with_roles( - relationship( - Project, - foreign_keys=[Project.account_id], - collection_class=attribute_keyed_dict('name'), - viewonly=True, - ), - read={'all'}, - ) - - def draft_projects_for(self, user: Account | None) -> list[Project]: - if user is not None: - return [ - membership.project - for membership in user.projects_as_crew_active_memberships.join( - Project - ).filter( - # Project is attached to this account - Project.account_id == self.id, - # Project is in draft state OR has a draft call for proposals - sa.or_(Project.state.DRAFT, Project.cfp_state.DRAFT), - ) - ] - return [] - - def unscheduled_projects_for(self, user: Account | None) -> list[Project]: - if user is not None: - return [ - membership.project - for membership in user.projects_as_crew_active_memberships.join( - Project - ).filter( - # Project is attached to this account - Project.account_id == self.id, - # Project is in draft state OR has a draft call for proposals - sa.or_(Project.state.PUBLISHED_WITHOUT_SESSIONS), - ) - ] - return [] - - @with_roles(read={'all'}, datasets={'primary', 'without_parent', 'related'}) - @cached_property - def published_project_count(self) -> int: - return ( - self.listed_projects.filter(Project.state.PUBLISHED).order_by(None).count() - ) - - @with_roles(grants_via={None: {'participant': 'member'}}) - @cached_property - def membership_project(self) -> Project | None: - """Return a project that has memberships flag enabled (temporary).""" - return self.projects.filter( - Project.boxoffice_data.op('@>')({'has_membership': True}) - ).first() - - class ProjectRedirect(TimestampMixin, Model): __tablename__ = 'project_redirect' - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False, primary_key=True ) - account: Mapped[Account] = relationship( - Account, backref=backref('project_redirects', cascade='all') - ) - parent: Mapped[Account] = sa.orm.synonym('account') - name: Mapped[str] = sa.orm.mapped_column( + account: Mapped[Account] = relationship(back_populates='project_redirects') + parent: Mapped[Account] = sa_orm.synonym('account') + name: Mapped[str] = sa_orm.mapped_column( sa.Unicode(250), nullable=False, primary_key=True ) - project_id: Mapped[int | None] = sa.orm.mapped_column( + project_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='SET NULL'), nullable=True ) - project: Mapped[Project | None] = relationship(Project, backref='redirects') + project: Mapped[Project | None] = relationship(back_populates='redirects') def __repr__(self) -> str: """Represent :class:`ProjectRedirect` as a string.""" @@ -978,17 +1538,15 @@ def migrate_account(cls, old_account: Account, new_account: Account) -> None: class ProjectLocation(TimestampMixin, Model): __tablename__ = 'project_location' #: Project we are tagging - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), primary_key=True, nullable=False ) - project: Mapped[Project] = relationship( - Project, backref=backref('locations', cascade='all') - ) + project: Mapped[Project] = relationship(back_populates='locations') #: Geonameid for this project - geonameid: Mapped[int] = sa.orm.mapped_column( + geonameid: Mapped[int] = sa_orm.mapped_column( sa.Integer, primary_key=True, nullable=False, index=True ) - primary: Mapped[bool] = sa.orm.mapped_column( + primary: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, default=True, nullable=False ) @@ -1000,15 +1558,86 @@ def __repr__(self) -> str: ) -@reopen(Commentset) -class __Commentset: - project: Mapped[Project | None] = with_roles( - relationship(Project, uselist=False, back_populates='commentset'), - grants_via={None: {'editor': 'document_subscriber'}}, - ) +# Tail imports +from .label import Label +from .project_membership import ProjectMembership +from .proposal import Proposal +from .rsvp import Rsvp +from .session import Session +from .sponsor_membership import ProjectSponsorMembership +from .update import Update +from .venue import Venue, VenueRoom + +if TYPE_CHECKING: + from .saved import SavedProject + from .sync_ticket import TicketClient, TicketEvent, TicketParticipant, TicketType + + +# Whether the project has any featured proposals. Returns `None` instead of +# a boolean if the project does not have any proposal. +# pylint: disable=protected-access +Project._has_featured_proposals = sa_orm.column_property( + sa.exists() + .where(Proposal.project_id == Project.id) + .where(Proposal.featured.is_(True)) + .correlate_except(Proposal), + deferred=True, +) +# pylint: enable=protected-access + + +# Project schedule column expressions. Guide: +# https://docs.sqlalchemy.org/en/13/orm/mapped_sql_expr.html#using-column-property +Project.schedule_start_at = with_roles( + sa_orm.column_property( + sa.select(sa.func.min(Session.start_at)) + .where(Session.start_at.is_not(None)) + .where(Session.project_id == Project.id) + .correlate_except(Session) + .scalar_subquery() + ), + read={'all'}, + datasets={'primary', 'without_parent'}, +) +Project.next_session_at = with_roles( + sa_orm.column_property( + sa.select(sa.func.min(sa.column('start_at'))) + .select_from( + sa.select(sa.func.min(Session.start_at).label('start_at')) + .where(Session.start_at.is_not(None)) + .where(Session.start_at >= sa.func.utcnow()) + .where(Session.project_id == Project.id) + .correlate_except(Session) + .union( + sa.select(Project.start_at.label('start_at')) + .where(Project.start_at.is_not(None)) + .where(Project.start_at >= sa.func.utcnow()) + .correlate(Project) + ) + .subquery() + ) + .scalar_subquery() + ), + read={'all'}, +) -# Tail imports -# pylint: disable=wrong-import-position -from .project_membership import ProjectMembership # isort:skip -from .venue import Venue # isort:skip # skipcq: FLK-E402 +Project.schedule_end_at = with_roles( + sa_orm.column_property( + sa.select(sa.func.max(Session.end_at)) + .where(Session.end_at.is_not(None)) + .where(Session.project_id == Project.id) + .correlate_except(Session) + .scalar_subquery() + ), + read={'all'}, + datasets={'primary', 'without_parent'}, +) + +with_roles( + Project.active_rsvps, + # This has to use a column reference because 'active_rsvps' has a join on Account + # and SQLAlchemy will interpret filter_by params to refer to attributes on the last + # joined model, not the first + grants_via={Rsvp.participant: {'participant', 'project_participant'}}, +) diff --git a/funnel/models/project_membership.py b/funnel/models/project_membership.py index 07b36f6f2..897b2e1cc 100644 --- a/funnel/models/project_membership.py +++ b/funnel/models/project_membership.py @@ -4,11 +4,9 @@ from werkzeug.utils import cached_property -from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles +from coaster.sqlalchemy import immutable, with_roles -from . import DynamicMapped, Mapped, Model, backref, declared_attr, relationship, sa -from .account import Account -from .helpers import reopen +from . import Mapped, Model, declared_attr, relationship, sa, sa_orm from .membership_mixin import ImmutableUserMembershipMixin from .project import Project @@ -29,10 +27,12 @@ #: ProjectMembership maps project's `account_admin` role to membership's `editor` #: role in addition to the recurring role grant map -project_membership_role_map: dict[str, str | set[str]] = { +project_membership_role_map: dict[str, set[str]] = { 'account_admin': {'account_admin', 'editor'} } -project_membership_role_map.update(project_child_role_map) +project_membership_role_map.update( + {k: {v} if isinstance(v, str) else v for k, v in project_child_role_map.items()} +) class ProjectMembership(ImmutableUserMembershipMixin, Model): @@ -103,46 +103,38 @@ class ProjectMembership(ImmutableUserMembershipMixin, Model): }, } - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False ) project: Mapped[Project] = with_roles( - relationship( - Project, - backref=backref( - 'crew_memberships', - lazy='dynamic', - cascade='all', - passive_deletes=True, - ), - ), + relationship(back_populates='crew_memberships'), grants_via={None: project_membership_role_map}, ) - parent_id: Mapped[int] = sa.orm.synonym('project_id') + parent_id: Mapped[int] = sa_orm.synonym('project_id') parent_id_column = 'project_id' - parent: Mapped[Project] = sa.orm.synonym('project') + parent: Mapped[Project] = sa_orm.synonym('project') # Project crew roles (at least one must be True): #: Editors can edit all common and editorial details of an event - is_editor: Mapped[bool] = sa.orm.mapped_column( + is_editor: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: Promoters are responsible for promotion and have write access #: to common details plus read access to everything else. Unlike #: editors, they cannot edit the schedule - is_promoter: Mapped[bool] = sa.orm.mapped_column( + is_promoter: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: Ushers help participants find their way around an event and have #: the ability to scan badges at the door - is_usher: Mapped[bool] = sa.orm.mapped_column( + is_usher: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: Optional label, indicating the member's role in the project - label = immutable( - sa.orm.mapped_column( + label: Mapped[str | None] = immutable( + sa_orm.mapped_column( sa.Unicode, sa.CheckConstraint( "label <> ''", name='project_crew_membership_label_check' @@ -156,7 +148,7 @@ class ProjectMembership(ImmutableUserMembershipMixin, Model): def __table_args__(cls) -> tuple: """Table arguments.""" try: - args = list(super().__table_args__) # type: ignore[misc] + args = list(super().__table_args__) except AttributeError: args = [] kwargs = args.pop(-1) if args and isinstance(args[-1], dict) else None @@ -185,122 +177,3 @@ def offered_roles(self) -> set[str]: if self.is_usher: roles.add('usher') return roles - - -# Project relationships: all crew, vs specific roles -@reopen(Project) -class __Project: - active_crew_memberships: DynamicMapped[ProjectMembership] = with_roles( - relationship( - ProjectMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectMembership.project_id == Project.id, - ProjectMembership.is_active, - ), - viewonly=True, - ), - grants_via={'member': {'editor', 'promoter', 'usher', 'participant', 'crew'}}, - ) - - active_editor_memberships: DynamicMapped[ProjectMembership] = relationship( - ProjectMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectMembership.project_id == Project.id, - ProjectMembership.is_active, - ProjectMembership.is_editor.is_(True), - ), - viewonly=True, - ) - - active_promoter_memberships: DynamicMapped[ProjectMembership] = relationship( - ProjectMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectMembership.project_id == Project.id, - ProjectMembership.is_active, - ProjectMembership.is_promoter.is_(True), - ), - viewonly=True, - ) - - active_usher_memberships: DynamicMapped[ProjectMembership] = relationship( - ProjectMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectMembership.project_id == Project.id, - ProjectMembership.is_active, - ProjectMembership.is_usher.is_(True), - ), - viewonly=True, - ) - - crew = DynamicAssociationProxy('active_crew_memberships', 'member') - editors = DynamicAssociationProxy('active_editor_memberships', 'member') - promoters = DynamicAssociationProxy('active_promoter_memberships', 'member') - ushers = DynamicAssociationProxy('active_usher_memberships', 'member') - - -# Similarly for users (add as needs come up) -@reopen(Account) -class __Account: - # pylint: disable=invalid-unary-operand-type - - # This relationship is only useful to check if the user has ever been a crew member. - # Most operations will want to use one of the active membership relationships. - projects_as_crew_memberships: DynamicMapped[ProjectMembership] = relationship( - ProjectMembership, - lazy='dynamic', - foreign_keys=[ProjectMembership.member_id], - viewonly=True, - ) - - # This is used to determine if it is safe to purge the subject's database record - projects_as_crew_noninvite_memberships: DynamicMapped[ - ProjectMembership - ] = relationship( - ProjectMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectMembership.member_id == Account.id, - ~ProjectMembership.is_invite, - ), - viewonly=True, - ) - projects_as_crew_active_memberships: DynamicMapped[ - ProjectMembership - ] = relationship( - ProjectMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectMembership.member_id == Account.id, - ProjectMembership.is_active, - ), - viewonly=True, - ) - - projects_as_crew = DynamicAssociationProxy( - 'projects_as_crew_active_memberships', 'project' - ) - - projects_as_editor_active_memberships: DynamicMapped[ - ProjectMembership - ] = relationship( - ProjectMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectMembership.member_id == Account.id, - ProjectMembership.is_active, - ProjectMembership.is_editor.is_(True), - ), - viewonly=True, - ) - - projects_as_editor = DynamicAssociationProxy( - 'projects_as_editor_active_memberships', 'project' - ) - - -Account.__active_membership_attrs__.add('projects_as_crew_active_memberships') -Account.__noninvite_membership_attrs__.add('projects_as_crew_noninvite_memberships') diff --git a/funnel/models/proposal.py b/funnel/models/proposal.py index 1124e3e51..071c7fc54 100644 --- a/funnel/models/proposal.py +++ b/funnel/models/proposal.py @@ -4,33 +4,42 @@ from collections.abc import Sequence from datetime import datetime as datetime_type +from typing import TYPE_CHECKING, Self + +from werkzeug.utils import cached_property from baseframe import __ from baseframe.filters import preview -from coaster.sqlalchemy import LazyRoleSet, StateManager, with_roles +from coaster.sqlalchemy import ( + DynamicAssociationProxy, + LazyRoleSet, + StateManager, + with_roles, +) from coaster.utils import LabeledEnum from . import ( BaseMixin, BaseScopedIdNameMixin, + DynamicMapped, Mapped, Model, Query, TSVectorType, UuidMixin, - backref, db, relationship, sa, + sa_orm, ) from .account import Account from .comment import SET_TYPE, Commentset from .helpers import ( MarkdownCompositeDocument, add_search_trigger, - reopen, visual_field_delimiter, ) +from .label import Label, ProposalLabelProxy, proposal_label from .project import Project from .project_membership import project_child_role_map from .reorder_mixin import ReorderProtoMixin @@ -123,33 +132,23 @@ class Proposal( # type: ignore[misc] ): __tablename__ = 'proposal' - created_by_id: Mapped[int] = sa.orm.mapped_column( + created_by_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) created_by: Mapped[Account] = with_roles( - relationship( - Account, - foreign_keys=[created_by_id], - backref=backref('created_proposals', cascade='all', lazy='dynamic'), - ), + relationship(back_populates='created_proposals'), grants={'creator', 'participant'}, ) - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( - relationship( - Project, - foreign_keys=[project_id], - backref=backref( - 'proposals', cascade='all', lazy='dynamic', order_by='Proposal.url_id' - ), - ), + relationship(back_populates='proposals'), grants_via={None: project_child_role_map}, ) - parent_id: Mapped[int] = sa.orm.synonym('project_id') + parent_id: Mapped[int] = sa_orm.synonym('project_id') parent_id_column = 'project_id' - parent: Mapped[Project] = sa.orm.synonym('project') + parent: Mapped[Project] = sa_orm.synonym('project') #: Reuse the `url_id` column from BaseScopedIdNameMixin as a sorting order column. #: `url_id` was a public number on talkfunnel.com, but is private on hasgeek.com. @@ -159,58 +158,55 @@ class Proposal( # type: ignore[misc] #: to all proposals, including drafts. A user-facing sequence will have gaps. #: Should numbering be required in the product, see `Update.number` for a better #: implementation. - seq: Mapped[int] = sa.orm.synonym('url_id') + seq: Mapped[int] = sa_orm.synonym('url_id') # TODO: Stand-in for `submitted_at` until proposals have a workflow-driven datetime - datetime: Mapped[datetime_type] = sa.orm.synonym('created_at') + datetime: Mapped[datetime_type] = sa_orm.synonym('created_at') - _state: Mapped[int] = sa.orm.mapped_column( + _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.Integer, StateManager.check_constraint('state', PROPOSAL_STATE), default=PROPOSAL_STATE.SUBMITTED, nullable=False, ) - state = StateManager('_state', PROPOSAL_STATE, doc="Current state of the proposal") + state = StateManager['Proposal']( + '_state', PROPOSAL_STATE, doc="Current state of the proposal" + ) - commentset_id: Mapped[int] = sa.orm.mapped_column( + commentset_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id'), nullable=False ) commentset: Mapped[Commentset] = relationship( - Commentset, - uselist=False, - lazy='joined', - cascade='all', - single_parent=True, - back_populates='proposal', + uselist=False, lazy='joined', single_parent=True, back_populates='proposal' ) body, body_text, body_html = MarkdownCompositeDocument.create( 'body', nullable=False, default='' ) - description: Mapped[str] = sa.orm.mapped_column( + description: Mapped[str] = sa_orm.mapped_column( sa.Unicode, nullable=False, default='' ) - custom_description: Mapped[bool] = sa.orm.mapped_column( + custom_description: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) - template: Mapped[bool] = sa.orm.mapped_column( + template: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) - featured: Mapped[bool] = sa.orm.mapped_column( + featured: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) - edited_at: Mapped[datetime_type | None] = sa.orm.mapped_column( + edited_at: Mapped[datetime_type | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Revision number maintained by SQLAlchemy, starting at 1 revisionid: Mapped[int] = with_roles( - sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + sa_orm.mapped_column(sa.Integer, nullable=False), read={'all'} ) - search_vector: Mapped[str] = sa.orm.mapped_column( + search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( 'title', 'description', @@ -231,6 +227,54 @@ class Proposal( # type: ignore[misc] deferred=True, ) + #: For reading and setting labels from the edit form + formlabels = ProposalLabelProxy() + + labels: Mapped[list[Label]] = with_roles( + relationship(Label, secondary=proposal_label, back_populates='proposals'), + read={'all'}, + ) + + all_memberships: DynamicMapped[ProposalMembership] = relationship( + lazy='dynamic', passive_deletes=True, back_populates='proposal' + ) + + # This relationship does not use `lazy='dynamic'` because it is expected to contain + # <2 records on average, and won't exceed 50 in the most extreme cases + memberships: Mapped[list[ProposalMembership]] = with_roles( + relationship( + primaryjoin=lambda: sa.and_( + ProposalMembership.proposal_id == Proposal.id, + ProposalMembership.is_active, + ), + order_by=lambda: ProposalMembership.seq, + viewonly=True, + ), + read={'all'}, + # These grants are authoritative and used instead of `offered_roles` above + grants_via={'member': {'submitter', 'editor'}}, + ) + + session: Mapped[Session | None] = relationship( + uselist=False, back_populates='proposal' + ) + + all_sponsor_memberships: DynamicMapped[ProposalSponsorMembership] = relationship( + lazy='dynamic', passive_deletes=True, back_populates='proposal' + ) + sponsor_memberships: DynamicMapped[ProposalSponsorMembership] = with_roles( + relationship( + lazy='dynamic', + primaryjoin=lambda: sa.and_( + ProposalSponsorMembership.proposal_id == Proposal.id, + ProposalSponsorMembership.is_active, + ), + order_by=lambda: ProposalSponsorMembership.seq, + viewonly=True, + ), + read={'all'}, + ) + __table_args__ = ( sa.UniqueConstraint( 'project_id', 'url_id', name='proposal_project_id_url_id_key' @@ -314,7 +358,7 @@ def __str__(self) -> str: def __format__(self, format_spec: str) -> str: if not format_spec: return self.title - return self.title.__format__(format_spec) + return format(self.title, format_spec) # State transitions state.add_conditional_state( @@ -458,6 +502,14 @@ def under_evaluation(self): def delete(self): pass + @property + def first_user(self) -> Account: + """Return the first credited member on the proposal, or creator if none.""" + for membership in self.memberships: + if not membership.is_uncredited: + return membership.member + return self.created_by + @with_roles(call={'project_editor'}) def move_to(self, project: Project) -> None: """Move to a new project and reset :attr:`url_id`.""" @@ -489,6 +541,13 @@ def getprev(self) -> Proposal | None: .first() ) + @with_roles(read={'all'}) + @cached_property + 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: @@ -506,7 +565,7 @@ def roles_for( return roles @classmethod - def all_public(cls) -> Query[Proposal]: + def all_public(cls) -> Query[Self]: return cls.query.join(Project).filter(Project.state.PUBLISHED, cls.state.PUBLIC) @classmethod @@ -525,84 +584,18 @@ class ProposalSuuidRedirect(BaseMixin, Model): __tablename__ = 'proposal_suuid_redirect' - suuid: Mapped[str] = sa.orm.mapped_column( + suuid: Mapped[str] = sa_orm.mapped_column( sa.Unicode(22), nullable=False, index=True ) - proposal_id: Mapped[int] = sa.orm.mapped_column( + proposal_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False ) - proposal: Mapped[Proposal] = relationship(Proposal) - - -@reopen(Commentset) -class __Commentset: - proposal: Mapped[Proposal] = relationship( - Proposal, uselist=False, back_populates='commentset' - ) - - -@reopen(Project) -class __Project: - @property - def proposals_all(self): - if self.subprojects: - return Proposal.query.filter( - Proposal.project_id.in_([self.id] + [s.id for s in self.subprojects]) - ) - return self.proposals - - @property - def proposals_by_state(self): - if self.subprojects: - basequery = Proposal.query.filter( - Proposal.project_id.in_([self.id] + [s.id for s in self.subprojects]) - ) - else: - basequery = Proposal.query.filter_by(project=self) - return Proposal.state.group( - basequery.filter( - ~(Proposal.state.DRAFT), ~(Proposal.state.DELETED) - ).order_by(sa.desc('created_at')) - ) - - @property - def proposals_by_confirmation(self): - if self.subprojects: - basequery = Proposal.query.filter( - Proposal.project_id.in_([self.id] + [s.id for s in self.subprojects]) - ) - else: - basequery = Proposal.query.filter_by(project=self) - return { - 'confirmed': basequery.filter(Proposal.state.CONFIRMED) - .order_by(sa.desc('created_at')) - .all(), - 'unconfirmed': basequery.filter( - ~(Proposal.state.CONFIRMED), - ~(Proposal.state.DRAFT), - ~(Proposal.state.DELETED), - ) - .order_by(sa.desc('created_at')) - .all(), - } - - # Whether the project has any featured proposals. Returns `None` instead of - # a boolean if the project does not have any proposal. - _has_featured_proposals: Mapped[bool | None] = sa.orm.column_property( - sa.exists() - .where(Proposal.project_id == Project.id) - .where(Proposal.featured.is_(True)) - .correlate_except(Proposal), - deferred=True, - ) - - @property - def has_featured_proposals(self) -> bool: - return bool(self._has_featured_proposals) - - with_roles(has_featured_proposals, read={'all'}) + proposal: Mapped[Proposal] = relationship() # Tail imports -# pylint: disable=wrong-import-position -from .proposal_membership import ProposalMembership # isort:skip +from .proposal_membership import ProposalMembership +from .sponsor_membership import ProposalSponsorMembership + +if TYPE_CHECKING: + from .session import Session diff --git a/funnel/models/proposal_membership.py b/funnel/models/proposal_membership.py index fba987e46..cfa141ce7 100644 --- a/funnel/models/proposal_membership.py +++ b/funnel/models/proposal_membership.py @@ -2,19 +2,18 @@ from __future__ import annotations +from typing import ClassVar + from werkzeug.utils import cached_property -from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles +from coaster.sqlalchemy import immutable, with_roles -from . import DynamicMapped, Mapped, Model, backref, relationship, sa -from .account import Account -from .helpers import reopen +from . import Mapped, Model, relationship, sa, sa_orm from .membership_mixin import ( FrozenAttributionProtoMixin, ImmutableUserMembershipMixin, ReorderMembershipProtoMixin, ) -from .project import Project from .proposal import Proposal __all__ = ['ProposalMembership'] @@ -75,10 +74,10 @@ class ProposalMembership( # type: ignore[misc] }, } - revoke_on_member_delete = False + revoke_on_member_delete: ClassVar[bool] = False proposal_id: Mapped[int] = with_roles( - sa.orm.mapped_column( + sa_orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False, @@ -87,32 +86,24 @@ class ProposalMembership( # type: ignore[misc] ) proposal: Mapped[Proposal] = with_roles( - relationship( - Proposal, - backref=backref( - 'all_memberships', - lazy='dynamic', - cascade='all', - passive_deletes=True, - ), - ), + relationship(back_populates='all_memberships'), read={'member', 'editor'}, grants_via={None: {'editor'}}, ) - parent_id: Mapped[int] = sa.orm.synonym('proposal_id') + parent_id: Mapped[int] = sa_orm.synonym('proposal_id') parent_id_column = 'proposal_id' - parent: Mapped[Proposal] = sa.orm.synonym('proposal') + parent: Mapped[Proposal] = sa_orm.synonym('proposal') #: Uncredited members are not listed in the main display, but can edit and may be #: listed in a details section. Uncredited memberships are for support roles such #: as copy editors. - is_uncredited: Mapped[bool] = sa.orm.mapped_column( + is_uncredited: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: Optional label, indicating the member's role on the proposal - label = immutable( - sa.orm.mapped_column( + label: Mapped[str | None] = immutable( + sa_orm.mapped_column( sa.Unicode, sa.CheckConstraint("label <> ''", name='proposal_membership_label_check'), nullable=True, @@ -124,88 +115,3 @@ def offered_roles(self) -> set[str]: """Roles offered by this membership record.""" # This method is not used. See the `Proposal.memberships` relationship below. return {'submitter', 'editor'} - - -# Project relationships -@reopen(Proposal) -class __Proposal: - created_by: Account - - # This relationship does not use `lazy='dynamic'` because it is expected to contain - # <2 records on average, and won't exceed 50 in the most extreme cases - memberships: Mapped[list[ProposalMembership]] = with_roles( - relationship( - ProposalMembership, - primaryjoin=sa.and_( - ProposalMembership.proposal_id == Proposal.id, - ProposalMembership.is_active, - ), - order_by=ProposalMembership.seq, - viewonly=True, - ), - read={'all'}, - # These grants are authoritative and used instead of `offered_roles` above - grants_via={'member': {'submitter', 'editor'}}, - ) - - @property - def first_user(self) -> Account: - """Return the first credited member on the proposal, or creator if none.""" - for membership in self.memberships: - if not membership.is_uncredited: - return membership.member - return self.created_by - - -@reopen(Account) -class __Account: - # pylint: disable=invalid-unary-operand-type - - all_proposal_memberships: DynamicMapped[ProposalMembership] = relationship( - ProposalMembership, - lazy='dynamic', - foreign_keys=[ProposalMembership.member_id], - viewonly=True, - ) - - noninvite_proposal_memberships: DynamicMapped[ProposalMembership] = relationship( - ProposalMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProposalMembership.member_id == Account.id, - ~ProposalMembership.is_invite, - ), - viewonly=True, - ) - - proposal_memberships: DynamicMapped[ProposalMembership] = relationship( - ProposalMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProposalMembership.member_id == Account.id, - ProposalMembership.is_active, - ), - viewonly=True, - ) - - proposals = DynamicAssociationProxy('proposal_memberships', 'proposal') - - @property - def public_proposal_memberships(self): - """Query for all proposal memberships to proposals that are public.""" - 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) - ) - ) - - public_proposals = DynamicAssociationProxy( - 'public_proposal_memberships', 'proposal' - ) - - -Account.__active_membership_attrs__.add('proposal_memberships') -Account.__noninvite_membership_attrs__.add('noninvite_proposal_memberships') diff --git a/funnel/models/reorder_mixin.py b/funnel/models/reorder_mixin.py index 1453252ce..a77720ecc 100644 --- a/funnel/models/reorder_mixin.py +++ b/funnel/models/reorder_mixin.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, ClassVar, TypeVar from uuid import UUID -from . import Mapped, QueryProperty, db, declarative_mixin, sa +from . import Mapped, QueryProperty, db, declarative_mixin, sa, sa_orm __all__ = ['ReorderProtoMixin'] @@ -81,8 +81,9 @@ def reorder_item(self: Reorderable, other: Reorderable, before: bool) -> None: cls.seq >= min(self.seq, other.seq), cls.seq <= max(self.seq, other.seq), ) + .populate_existing() # Force reload `.seq` into session cache .with_for_update(of=cls) # Lock these rows to prevent a parallel update - .options(sa.orm.load_only(cls.id, cls.seq)) + .options(sa_orm.load_only(cls.id, cls.seq)) .order_by(*order_columns) .all() ) @@ -101,13 +102,11 @@ def reorder_item(self: Reorderable, other: Reorderable, before: bool) -> None: new_seq_number = self.seq # Temporarily give self an out-of-bounds number self.seq = ( - sa.select( # type: ignore[assignment] - sa.func.coalesce(sa.func.max(cls.seq) + 1, 1) - ) + sa.select(sa.func.coalesce(sa.func.max(cls.seq) + 1, 1)) .where(self.parent_scoped_reorder_query_filter) .scalar_subquery() ) - # Flush it so the db doesn't complain when there's a unique constraint + # Flush it so the db does not complain when there's a unique constraint db.session.flush() # Reassign all remaining sequence numbers for reorderable_item in items_to_reorder[1:]: # Skip 0, which is self diff --git a/funnel/models/rsvp.py b/funnel/models/rsvp.py index 66026e9a5..16fa3181d 100644 --- a/funnel/models/rsvp.py +++ b/funnel/models/rsvp.py @@ -2,29 +2,16 @@ from __future__ import annotations -from typing import Literal, cast, overload +from typing import Literal, Self, overload from flask import current_app -from werkzeug.utils import cached_property from baseframe import __ from coaster.sqlalchemy import StateManager, with_roles from coaster.utils import LabeledEnum -from . import ( - Mapped, - Model, - NoIdMixin, - Query, - UuidMixin, - backref, - db, - relationship, - sa, - types, -) +from . import Mapped, Model, NoIdMixin, UuidMixin, db, relationship, sa, sa_orm, types from .account import Account, AccountEmail, AccountEmailClaim, AccountPhone -from .helpers import reopen from .project import Project from .project_membership import project_child_role_map @@ -42,32 +29,32 @@ class RSVP_STATUS(LabeledEnum): # noqa: N801 class Rsvp(UuidMixin, NoIdMixin, Model): __tablename__ = 'rsvp' - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False, primary_key=True ) project: Mapped[Project] = with_roles( - relationship(Project, backref=backref('rsvps', cascade='all', lazy='dynamic')), + relationship(back_populates='rsvps'), read={'owner', 'project_promoter'}, grants_via={None: project_child_role_map}, datasets={'primary'}, ) - participant_id: Mapped[int] = sa.orm.mapped_column( + participant_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False, primary_key=True ) participant: Mapped[Account] = with_roles( - relationship(Account, backref=backref('rsvps', cascade='all', lazy='dynamic')), + relationship(back_populates='rsvps'), read={'owner', 'project_promoter'}, grants={'owner'}, datasets={'primary', 'without_parent'}, ) form: Mapped[types.jsonb | None] = with_roles( - sa.orm.mapped_column(), + sa_orm.mapped_column(), rw={'owner'}, read={'project_promoter'}, datasets={'primary', 'without_parent', 'related'}, ) - _state: Mapped[str] = sa.orm.mapped_column( + _state: Mapped[str] = sa_orm.mapped_column( 'state', sa.CHAR(1), StateManager.check_constraint('state', RSVP_STATUS), @@ -75,7 +62,7 @@ class Rsvp(UuidMixin, NoIdMixin, Model): nullable=False, ) state = with_roles( - StateManager('_state', RSVP_STATUS, doc="RSVP answer"), + StateManager['Rsvp']('_state', RSVP_STATUS, doc="RSVP answer"), call={'owner', 'project_promoter'}, ) @@ -134,7 +121,7 @@ def participant_email(self) -> AccountEmail | None: return self.participant.transport_for_email(self.project.account) @with_roles(call={'owner', 'project_promoter'}) - def participant_phone(self) -> AccountEmail | None: + def participant_phone(self) -> AccountPhone | None: """Participant's preferred phone number for this registration.""" return self.participant.transport_for_sms(self.project.account) @@ -170,27 +157,27 @@ def migrate_account(cls, old_account: Account, new_account: Account) -> None: @overload @classmethod - def get_for(cls, project: Project, user: Account, create: Literal[True]) -> Rsvp: + def get_for(cls, project: Project, account: Account, create: Literal[True]) -> Self: ... @overload @classmethod def get_for( cls, project: Project, account: Account, create: Literal[False] - ) -> Rsvp | None: + ) -> Self | None: ... @overload @classmethod def get_for( - cls, project: Project, account: Account | None, create=False - ) -> Rsvp | None: + cls, project: Project, account: Account | None, create: bool = False + ) -> Self | None: ... @classmethod def get_for( - cls, project: Project, account: Account | None, create=False - ) -> Rsvp | None: + cls, project: Project, account: Account | None, create: bool = False + ) -> Self | None: if account is not None: result = cls.query.get((project.id, account.id)) if not result and create: @@ -198,72 +185,3 @@ def get_for( db.session.add(result) return result return None - - -@reopen(Project) -class __Project: - @property - def active_rsvps(self): - return self.rsvps.join(Account).filter(Rsvp.state.YES, Account.state.ACTIVE) - - with_roles( - active_rsvps, - grants_via={Rsvp.participant: {'participant', 'project_participant'}}, - ) - - @overload - def rsvp_for(self, account: Account, create: Literal[True]) -> Rsvp: - ... - - @overload - def rsvp_for(self, account: Account | None, create: Literal[False]) -> Rsvp | None: - ... - - def rsvp_for(self, account: Account | None, create=False) -> Rsvp | None: - return Rsvp.get_for(cast(Project, self), account, create) - - def rsvps_with(self, status: str): - return ( - cast(Project, self) - .rsvps.join(Account) - .filter( - Account.state.ACTIVE, - Rsvp._state == status, # pylint: disable=protected-access - ) - ) - - def rsvp_counts(self) -> dict[str, int]: - return dict( - db.session.query( - Rsvp._state, # pylint: disable=protected-access - sa.func.count(Rsvp._state), # pylint: disable=protected-access - ) - .join(Account) - .filter(Account.state.ACTIVE, Rsvp.project == self) - .group_by(Rsvp._state) # pylint: disable=protected-access - .all() - ) - - @cached_property - def rsvp_count_going(self) -> int: - return ( - cast(Project, self) - .rsvps.join(Account) - .filter(Account.state.ACTIVE, Rsvp.state.YES) - .count() - ) - - -@reopen(Account) -class __Account: - @property - def rsvp_followers(self) -> Query[Account]: - """All users with an active RSVP in a project.""" - return ( - Account.query.filter(Account.state.ACTIVE) - .join(Rsvp, Rsvp.participant_id == Account.id) - .join(Project, Rsvp.project_id == Project.id) - .filter(Rsvp.state.YES, Project.state.PUBLISHED, Project.account == self) - ) - - with_roles(rsvp_followers, grants={'follower'}) diff --git a/funnel/models/saved.py b/funnel/models/saved.py index 48c9fb13a..4fb250b80 100644 --- a/funnel/models/saved.py +++ b/funnel/models/saved.py @@ -7,9 +7,8 @@ from coaster.sqlalchemy import LazyRoleSet, with_roles -from . import Mapped, Model, NoIdMixin, backref, db, relationship, sa +from . import Mapped, Model, NoIdMixin, db, relationship, sa, sa_orm from .account import Account -from .helpers import reopen from .project import Project from .session import Session @@ -18,44 +17,32 @@ class SavedProject(NoIdMixin, Model): __tablename__ = 'saved_project' #: User account that saved this project - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id', ondelete='CASCADE'), nullable=False, primary_key=True, ) - account: Mapped[Account] = relationship( - Account, - backref=backref('saved_projects', lazy='dynamic', passive_deletes=True), + account: Mapped[Account] = with_roles( + relationship(back_populates='saved_projects'), grants={'owner'} ) #: Project that was saved - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False, primary_key=True, index=True, ) - project: Mapped[Project] = relationship( - Project, - backref=backref('saved_by', lazy='dynamic', passive_deletes=True), - ) + project: Mapped[Project] = relationship(back_populates='saves') #: Timestamp when the save happened - saved_at: Mapped[datetime] = sa.orm.mapped_column( + saved_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) #: User's plaintext note to self on why they saved this (optional) - description: Mapped[str | None] = sa.orm.mapped_column( + description: Mapped[str | None] = sa_orm.mapped_column( 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.""" @@ -71,33 +58,27 @@ class SavedSession(NoIdMixin, Model): __tablename__ = 'saved_session' #: User account that saved this session - account_id: Mapped[int] = sa.orm.mapped_column( + account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id', ondelete='CASCADE'), nullable=False, primary_key=True, ) - account: Mapped[Account] = relationship( - Account, - backref=backref('saved_sessions', lazy='dynamic', passive_deletes=True), - ) + account: Mapped[Account] = relationship(back_populates='saved_sessions') #: Session that was saved - session_id: Mapped[int] = sa.orm.mapped_column( + session_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('session.id', ondelete='CASCADE'), nullable=False, primary_key=True, index=True, ) - session: Mapped[Session] = relationship( - Session, - backref=backref('saved_by', lazy='dynamic', passive_deletes=True), - ) + session: Mapped[Session] = relationship(back_populates='saves') #: Timestamp when the save happened - saved_at: Mapped[datetime] = sa.orm.mapped_column( + saved_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) #: User's plaintext note to self on why they saved this (optional) - description: Mapped[str | None] = sa.orm.mapped_column( + description: Mapped[str | None] = sa_orm.mapped_column( sa.UnicodeText, nullable=True ) @@ -112,26 +93,11 @@ def roles_for( @classmethod def migrate_account(cls, old_account: Account, new_account: Account) -> None: """Migrate one account's data to another when merging accounts.""" - project_ids = {ss.project_id for ss in new_account.saved_sessions} + session_ids = {ss.session_id for ss in new_account.saved_sessions} for ss in old_account.saved_sessions: - if ss.project_id not in project_ids: + if ss.session_id not in session_ids: ss.account = new_account else: # TODO: `if ss.description`, don't discard, but add it to existing's # description db.session.delete(ss) - - -@reopen(Account) -class __Account: - def saved_sessions_in(self, project): - return self.saved_sessions.join(Session).filter(Session.project == project) - - -@reopen(Project) -class __Project: - @with_roles(call={'all'}) - def is_saved_by(self, account: Account) -> bool: - return ( - account is not None and self.saved_by.filter_by(account=account).notempty() - ) diff --git a/funnel/models/session.py b/funnel/models/session.py index 1d1b9fdf1..60013938d 100644 --- a/funnel/models/session.py +++ b/funnel/models/session.py @@ -2,17 +2,13 @@ from __future__ import annotations -from collections import OrderedDict, defaultdict from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Self -from flask_babel import format_date, get_locale -from isoweek import Week from werkzeug.utils import cached_property from baseframe import localize_timezone from coaster.sqlalchemy import with_roles -from coaster.utils import utcnow from . import ( BaseScopedIdNameMixin, @@ -22,24 +18,22 @@ Query, TSVectorType, UuidMixin, - backref, db, hybrid_property, relationship, sa, + sa_orm, ) from .account import Account from .helpers import ( ImgeeType, MarkdownCompositeDocument, add_search_trigger, - reopen, visual_field_delimiter, ) from .project import Project from .project_membership import project_child_role_map from .proposal import Proposal -from .venue import VenueRoom from .video_mixin import VideoMixin __all__ = ['Session'] @@ -48,57 +42,53 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): __tablename__ = 'session' - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( - relationship( - Project, backref=backref('sessions', cascade='all', lazy='dynamic') - ), + relationship(back_populates='sessions'), grants_via={None: project_child_role_map}, ) - parent: Mapped[Project] = sa.orm.synonym('project') + parent: Mapped[Project] = sa_orm.synonym('project') description, description_text, description_html = MarkdownCompositeDocument.create( 'description', default='', nullable=False ) - proposal_id: Mapped[int] = sa.orm.mapped_column( + proposal_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id'), nullable=True, unique=True ) - proposal: Mapped[Proposal | None] = relationship( - Proposal, backref=backref('session', uselist=False, cascade='all') - ) - speaker: Mapped[str | None] = sa.orm.mapped_column( + proposal: Mapped[Proposal | None] = relationship(back_populates='session') + speaker: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode(200), default=None, nullable=True ) - start_at: Mapped[datetime | None] = sa.orm.mapped_column( + start_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True ) - end_at: Mapped[datetime | None] = sa.orm.mapped_column( + end_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True ) - venue_room_id: Mapped[int | None] = sa.orm.mapped_column( + venue_room_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('venue_room.id'), nullable=True ) - venue_room: Mapped[VenueRoom | None] = relationship(VenueRoom, backref='sessions') - is_break: Mapped[bool] = sa.orm.mapped_column( + venue_room: Mapped[VenueRoom | None] = relationship(back_populates='sessions') + is_break: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, default=False, nullable=False ) - featured: Mapped[bool] = sa.orm.mapped_column( + featured: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, default=False, nullable=False ) - is_restricted_video: Mapped[bool] = sa.orm.mapped_column( + is_restricted_video: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, default=False, nullable=False ) - banner_image_url: Mapped[str | None] = sa.orm.mapped_column( + banner_image_url: Mapped[str | None] = sa_orm.mapped_column( ImgeeType, nullable=True ) #: Version number maintained by SQLAlchemy, used for vCal files, starting at 1 revisionid: Mapped[int] = with_roles( - sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} + sa_orm.mapped_column(sa.Integer, nullable=False), read={'all'} ) - search_vector: Mapped[str] = sa.orm.mapped_column( + search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( 'title', 'description_text', @@ -120,6 +110,10 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): deferred=True, ) + saves: DynamicMapped[SavedSession] = relationship( + lazy='dynamic', passive_deletes=True, back_populates='session' + ) + __table_args__ = ( sa.UniqueConstraint('project_id', 'url_id'), sa.CheckConstraint( @@ -129,7 +123,7 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): start_at.is_not(None), end_at.is_not(None), end_at > start_at, - end_at <= start_at + sa.text("INTERVAL '1 day'"), + end_at <= start_at + timedelta(days=1), ), ), 'session_start_at_end_at_check', @@ -285,434 +279,15 @@ def make_unscheduled(self) -> None: self.end_at = None @classmethod - def all_public(cls) -> Query[Session]: + def all_public(cls) -> Query[Self]: return cls.query.join(Project).filter(Project.state.PUBLISHED, cls.scheduled) add_search_trigger(Session, 'search_vector') -@reopen(VenueRoom) -class __VenueRoom: - scheduled_sessions: Mapped[list[Session]] = relationship( - Session, - primaryjoin=sa.and_( - Session.venue_room_id == VenueRoom.id, - Session.scheduled, - ), - viewonly=True, - ) - - -@reopen(Project) -class __Project: - # Project schedule column expressions. Guide: - # https://docs.sqlalchemy.org/en/13/orm/mapped_sql_expr.html#using-column-property - schedule_start_at: Mapped[datetime | None] = with_roles( - sa.orm.column_property( - sa.select(sa.func.min(Session.start_at)) - .where(Session.start_at.is_not(None)) - .where(Session.project_id == Project.id) - .correlate_except(Session) - .scalar_subquery() - ), - read={'all'}, - datasets={'primary', 'without_parent'}, - ) - - next_session_at: Mapped[datetime | None] = with_roles( - sa.orm.column_property( - sa.select(sa.func.min(sa.column('start_at'))) - .select_from( - sa.select(sa.func.min(Session.start_at).label('start_at')) - .where(Session.start_at.is_not(None)) - .where(Session.start_at >= sa.func.utcnow()) - .where(Session.project_id == Project.id) - .correlate_except(Session) # type: ignore[arg-type] - .union( - sa.select( - Project.start_at.label('start_at') # type: ignore[has-type] - ) - .where(Project.start_at.is_not(None)) # type: ignore[has-type] - .where( - Project.start_at >= sa.func.utcnow() # type: ignore[has-type] - ) - .correlate(Project) - ) - .subquery() - ) - .scalar_subquery() - ), - read={'all'}, - ) - - schedule_end_at: Mapped[datetime | None] = with_roles( - sa.orm.column_property( - sa.select(sa.func.max(Session.end_at)) - .where(Session.end_at.is_not(None)) - .where(Session.project_id == Project.id) - .correlate_except(Session) - .scalar_subquery() - ), - read={'all'}, - datasets={'primary', 'without_parent'}, - ) - - @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) - @cached_property - def schedule_start_at_localized(self) -> datetime | None: - return ( - localize_timezone(self.schedule_start_at, tz=self.timezone) - if self.schedule_start_at - else None - ) - - @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) - @cached_property - def schedule_end_at_localized(self) -> datetime | None: - return ( - localize_timezone(self.schedule_end_at, tz=self.timezone) - if self.schedule_end_at - else None - ) - - @with_roles(read={'all'}) - @cached_property - def session_count(self) -> int: - return self.sessions.filter(Session.start_at.is_not(None)).count() - - featured_sessions: Mapped[list[Session]] = with_roles( - relationship( - Session, - order_by=Session.start_at.asc(), - primaryjoin=sa.and_( - Session.project_id == Project.id, Session.featured.is_(True) - ), - viewonly=True, - ), - read={'all'}, - ) - scheduled_sessions: Mapped[list[Session]] = with_roles( - relationship( - Session, - order_by=Session.start_at.asc(), - primaryjoin=sa.and_( - Session.project_id == Project.id, - Session.scheduled, - ), - viewonly=True, - ), - read={'all'}, - ) - unscheduled_sessions: Mapped[list[Session]] = with_roles( - relationship( - Session, - order_by=Session.start_at.asc(), - primaryjoin=sa.and_( - Session.project_id == Project.id, - Session.scheduled.is_not(True), - ), - viewonly=True, - ), - read={'all'}, - ) - - sessions_with_video: DynamicMapped[Session] = with_roles( - relationship( - Session, - lazy='dynamic', - primaryjoin=sa.and_( - Project.id == Session.project_id, - Session.video_id.is_not(None), - Session.video_source.is_not(None), - ), - viewonly=True, - ), - read={'all'}, - ) - - @with_roles(read={'all'}) - @cached_property - def has_sessions_with_video(self) -> bool: - return self.query.session.query(self.sessions_with_video.exists()).scalar() - - def next_session_from(self, timestamp: datetime) -> Session | None: - """Find the next session in this project from given timestamp.""" - return ( - self.sessions.filter( - Session.start_at.is_not(None), Session.start_at >= timestamp - ) - .order_by(Session.start_at.asc()) - .first() - ) - - @with_roles(call={'all'}) - def next_starting_at( # type: ignore[misc] - self: Project, timestamp: datetime | None = None - ) -> datetime | None: - """ - Return timestamp of next session from given timestamp. - - Supplements :attr:`next_session_at` to also consider projects without sessions. - """ - # If there's no `self.start_at`, there is no session either - if self.start_at is not None: - if timestamp is None: - timestamp = utcnow() - # If `self.start_at` is in the future, it is guaranteed to be the closest - # timestamp, so return it directly - if self.start_at >= timestamp: - return self.start_at - # In the past? Then look for a session and return that timestamp, if any - return ( - db.session.query(sa.func.min(Session.start_at)) - .filter( - Session.start_at.is_not(None), - Session.start_at >= timestamp, - Session.project == self, - ) - .scalar() - ) - - return None - - @classmethod - def starting_at( # type: ignore[misc] - cls: type[Project], timestamp: datetime, within: timedelta, gap: timedelta - ) -> Query[Project]: - """ - Return projects that are about to start, for sending notifications. - - :param datetime timestamp: The timestamp to look for new sessions at - :param timedelta within: Find anything at timestamp + within delta. Lookup will - be for sessions where timestamp >= start_at < timestamp+within - :param timedelta gap: A project will be considered to be starting if it has no - sessions ending within the gap period before the timestamp - - Typical use of this method is from a background worker that calls it at - intervals of five minutes with parameters (timestamp, within 5m, 60m gap). - """ - # As a rule, start_at is queried with >= and <, end_at with > and <= because - # they represent inclusive lower and upper bounds. - - # Check project starting time before looking for individual sessions, as some - # projects will have no sessions - return ( - cls.query.filter( - cls.id.in_( - db.session.query(sa.func.distinct(Session.project_id)).filter( - Session.start_at.is_not(None), - Session.start_at >= timestamp, - Session.start_at < timestamp + within, - Session.project_id.notin_( - db.session.query( - sa.func.distinct(Session.project_id) - ).filter( - Session.start_at.is_not(None), - sa.or_( - sa.and_( - Session.start_at >= timestamp - gap, - Session.start_at < timestamp, - ), - sa.and_( - Session.end_at > timestamp - gap, - Session.end_at <= timestamp, - ), - ), - ) - ), - ) - ) - ) - .join(Session.project) - .filter(cls.state.PUBLISHED) - ).union( - cls.query.filter( - cls.state.PUBLISHED, - cls.start_at.is_not(None), - cls.start_at >= timestamp, - cls.start_at < timestamp + within, - ) - ) - - @with_roles(call={'all'}) - def current_sessions(self) -> dict | None: - if self.start_at is None or (self.start_at > utcnow() + timedelta(minutes=30)): - return None - - current_sessions = ( - self.sessions.outerjoin(VenueRoom) - .filter(Session.start_at <= sa.func.utcnow() + timedelta(minutes=30)) - .filter(Session.end_at > sa.func.utcnow()) - .order_by(Session.start_at.asc(), VenueRoom.seq.asc()) - ) - - return { - 'sessions': [ - session.current_access(datasets=('without_parent', 'related')) - for session in current_sessions - ], - 'rooms': [ - room.current_access(datasets=('without_parent', 'related')) - for room in self.rooms - ], - } - - # TODO: Use TypedDict for return type - def calendar_weeks(self, leading_weeks: bool = True) -> dict[str, Any]: - # session_dates is a list of tuples in this format - - # (date, day_start_at, day_end_at, event_count) - if self.schedule_start_at: - session_dates = list( - db.session.query( - sa.func.date_trunc( - 'day', sa.func.timezone(self.timezone.zone, Session.start_at) - ).label('date'), - sa.func.min(Session.start_at).label('day_start_at'), - sa.func.max(Session.end_at).label('day_end_at'), - sa.func.count().label('count'), - ) - .select_from(Session) - .filter( - Session.project == self, - Session.start_at.is_not(None), - Session.end_at.is_not(None), - ) - .group_by('date') - .order_by('date') - ) - elif self.start_at: - start_at = self.start_at_localized - end_at = self.end_at_localized - if start_at.date() == end_at.date(): - session_dates = [(start_at, start_at, end_at, 1)] - else: - session_dates = [ - ( - start_at + timedelta(days=plusdays), - start_at + timedelta(days=plusdays), - end_at - timedelta(days=plusdays), - 1, - ) - for plusdays in range( - ( - end_at.replace(hour=1, minute=0, second=0, microsecond=0) - - start_at.replace( - hour=0, minute=0, second=0, microsecond=0 - ) - ).days - + 1 - ) - ] - else: - session_dates = [] - - session_dates_dict = { - date.date(): { - 'day_start_at': day_start_at, - 'day_end_at': day_end_at, - 'count': count, - } - for date, day_start_at, day_end_at, count in session_dates - } - - # FIXME: This doesn't work. This code needs to be tested in isolation - # session_dates = ( - # db.session.query( - # sa.cast( - # sa.func.date_trunc( - # 'day', sa.func.timezone(self.timezone.zone, Session.start_at) - # ), - # sa.Date, - # ).label('date'), - # sa.func.count().label('count'), - # ) - # .filter(Session.project == self, Session.scheduled) - # .group_by(sa.text('date')) - # .order_by(sa.text('date')) - # ) - - # if the project's week is within next 2 weeks, send current week as well - now = utcnow().astimezone(self.timezone) - current_week = Week.withdate(now) - - if leading_weeks and self.schedule_start_at is not None: - schedule_start_week = Week.withdate(self.schedule_start_at) - - # session_dates is a list of tuples in this format - - # (date, day_start_at, day_end_at, event_count) - # as these days dont have any event, day_start/end_at are None, - # and count is 0. - if ( - schedule_start_week > current_week - and (schedule_start_week - current_week) <= 2 - ): - if (schedule_start_week - current_week) == 2: - # add this so that the next week's dates - # are also included in the calendar. - session_dates.insert(0, (now + timedelta(days=7), None, None, 0)) - session_dates.insert(0, (now, None, None, 0)) - - weeks: dict[str, dict[str, Any]] = defaultdict(dict) - today = now.date() - for project_date, _day_start_at, _day_end_at, session_count in session_dates: - weekobj = Week.withdate(project_date) - weekid = weekobj.isoformat() - if weekid not in weeks: - weeks[weekid]['year'] = weekobj.year - # Order is important, and we need dict to count easily - weeks[weekid]['dates'] = OrderedDict() - for wdate in weekobj.days(): - weeks[weekid]['dates'].setdefault(wdate, 0) - if project_date.date() == wdate: - # If the event is over don't set upcoming for current week - if wdate >= today and weekobj >= current_week and session_count > 0: - weeks[weekid]['upcoming'] = True - weeks[weekid]['dates'][wdate] += session_count - if 'month' not in weeks[weekid]: - weeks[weekid]['month'] = format_date(wdate, 'MMM') - # Extract sorted weeks as a list - weeks_list = [v for k, v in sorted(weeks.items())] - - for week in weeks_list: - # Convering to JSON messes up dictionary key order even though we used - # OrderedDict. This turns the OrderedDict into a list of tuples and JSON - # preserves that order. - week['dates'] = [ - { - 'isoformat': date.isoformat(), - 'day': format_date(date, 'd'), - 'count': count, - 'day_start_at': ( - session_dates_dict[date]['day_start_at'] - .astimezone(self.timezone) - .strftime('%I:%M %p') - if date in session_dates_dict.keys() - 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() - else None - ), - } - for date, count in week['dates'].items() - ] - return { - 'locale': get_locale(), - 'weeks': weeks_list, - 'today': now.date().isoformat(), - 'days': [format_date(day, 'EEE') for day in Week.thisweek().days()], - } - - @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) - @cached_property - def calendar_weeks_full(self) -> dict[str, Any]: # TODO: Use TypedDict - return self.calendar_weeks(leading_weeks=True) +# Tail imports +from .venue import VenueRoom - @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) - @cached_property - def calendar_weeks_compact(self) -> dict[str, Any]: # TODO: Use TypedDict - return self.calendar_weeks(leading_weeks=False) +if TYPE_CHECKING: + from .saved import SavedSession diff --git a/funnel/models/shortlink.py b/funnel/models/shortlink.py index a30cecdd3..3b8c1b918 100644 --- a/funnel/models/shortlink.py +++ b/funnel/models/shortlink.py @@ -15,7 +15,17 @@ from coaster.sqlalchemy import immutable, with_roles -from . import Mapped, Model, NoIdMixin, UrlType, db, hybrid_property, relationship, sa +from . import ( + Mapped, + Model, + NoIdMixin, + UrlType, + db, + hybrid_property, + relationship, + sa, + sa_orm, +) from .account import Account from .helpers import profanity @@ -200,25 +210,25 @@ class Shortlink(NoIdMixin, Model): id: Mapped[int] = with_roles( # noqa: A003 # id cannot use the `immutable` wrapper because :meth:`new` changes the id when # handling collisions. This needs an "immutable after commit" handler - sa.orm.mapped_column( + sa_orm.mapped_column( sa.BigInteger, autoincrement=False, nullable=False, primary_key=True ), read={'all'}, ) #: URL target of this shortlink url: Mapped[furl] = with_roles( - immutable(sa.orm.mapped_column(UrlType, nullable=False, index=True)), + immutable(sa_orm.mapped_column(UrlType, nullable=False, index=True)), read={'all'}, ) #: Id of account that created this shortlink (optional) - created_by_id: Mapped[int | None] = sa.orm.mapped_column( + created_by_id: Mapped[int | None] = sa_orm.mapped_column( sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=True ) #: Account that created this shortlink (optional) - created_by: Mapped[Account | None] = relationship(Account) + created_by: Mapped[Account | None] = relationship() #: Is this link enabled? If not, render 410 Gone - enabled: Mapped[bool] = sa.orm.mapped_column( + enabled: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=True ) @@ -226,7 +236,7 @@ class Shortlink(NoIdMixin, Model): def name(self) -> str: """Return string representation of id, for use in short URLs.""" if self.id is None: - return '' + return '' # type: ignore[unreachable] return bigint_to_name(self.id) @name.inplace.setter @@ -242,13 +252,13 @@ def _name_comparator(cls): # --- Validators - @sa.orm.validates('id') + @sa_orm.validates('id') def _validate_id_not_zero(self, _key: str, value: int) -> int: if value == 0: raise ValueError("Id cannot be zero") return value - @sa.orm.validates('url') + @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 @@ -369,7 +379,7 @@ def name_available(cls, name: str) -> bool: try: existing = db.session.query( cls.query.filter(cls.name == name) - .options(sa.orm.load_only(cls.id)) + .options(sa_orm.load_only(cls.id)) .exists() ).scalar() return not existing @@ -389,7 +399,7 @@ def get(cls, name: str | bytes, ignore_enabled: bool = False) -> Shortlink | Non except (ValueError, TypeError): return None obj = db.session.get( - cls, idv, options=[sa.orm.load_only(cls.id, cls.url, cls.enabled)] + cls, idv, options=[sa_orm.load_only(cls.id, cls.url, cls.enabled)] ) if obj is not None and (ignore_enabled or obj.enabled): return obj diff --git a/funnel/models/site_membership.py b/funnel/models/site_membership.py index c1167435f..f8576ff64 100644 --- a/funnel/models/site_membership.py +++ b/funnel/models/site_membership.py @@ -4,9 +4,7 @@ from werkzeug.utils import cached_property -from . import Mapped, Model, declared_attr, relationship, sa -from .account import Account -from .helpers import reopen +from . import Mapped, Model, declared_attr, sa, sa_orm from .membership_mixin import ImmutableUserMembershipMixin __all__ = ['SiteMembership'] @@ -46,19 +44,19 @@ class SiteMembership(ImmutableUserMembershipMixin, Model): # Site admin roles (at least one must be True): #: Comment moderators can delete comments - is_comment_moderator: Mapped[bool] = sa.orm.mapped_column( + is_comment_moderator: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: User moderators can suspend users - is_user_moderator: Mapped[bool] = sa.orm.mapped_column( + is_user_moderator: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: Site editors can feature or reject projects - is_site_editor: Mapped[bool] = sa.orm.mapped_column( + is_site_editor: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) #: Sysadmins can manage technical settings - is_sysadmin: Mapped[bool] = sa.orm.mapped_column( + is_sysadmin: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, nullable=False, default=False ) @@ -67,7 +65,7 @@ class SiteMembership(ImmutableUserMembershipMixin, Model): def __table_args__(cls) -> tuple: """Table arguments.""" try: - args = list(super().__table_args__) # type: ignore[misc] + args = list(super().__table_args__) except AttributeError: args = [] args.append( @@ -111,56 +109,3 @@ def offered_roles(self) -> set[str]: if self.is_sysadmin: roles.add('sysadmin') return roles - - -@reopen(Account) -class __Account: - # Singular, as only one can be active - active_site_membership: Mapped[SiteMembership] = relationship( - SiteMembership, - lazy='select', - primaryjoin=sa.and_( - SiteMembership.member_id == Account.id, # type: ignore[has-type] - SiteMembership.is_active, - ), - viewonly=True, - uselist=False, - ) - - @cached_property - def is_comment_moderator(self) -> bool: - """Test if this user is a comment moderator.""" - return ( - self.active_site_membership is not None - and self.active_site_membership.is_comment_moderator - ) - - @cached_property - def is_user_moderator(self) -> bool: - """Test if this user is an account moderator.""" - return ( - self.active_site_membership is not None - and self.active_site_membership.is_user_moderator - ) - - @cached_property - def is_site_editor(self) -> bool: - """Test if this user is a site editor.""" - return ( - self.active_site_membership is not None - and self.active_site_membership.is_site_editor - ) - - @cached_property - def is_sysadmin(self) -> bool: - """Test if this user is a sysadmin.""" - return ( - self.active_site_membership is not None - and self.active_site_membership.is_sysadmin - ) - - # site_admin means user has one or more of above roles - @cached_property - def is_site_admin(self) -> bool: - """Test if this user has any site-level admin rights.""" - return self.active_site_membership is not None diff --git a/funnel/models/sponsor_membership.py b/funnel/models/sponsor_membership.py index 52b8bd9de..829d7186f 100644 --- a/funnel/models/sponsor_membership.py +++ b/funnel/models/sponsor_membership.py @@ -2,13 +2,13 @@ from __future__ import annotations +from typing import ClassVar + from werkzeug.utils import cached_property -from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles +from coaster.sqlalchemy import immutable -from . import DynamicMapped, Mapped, Model, backref, db, relationship, sa -from .account import Account -from .helpers import reopen +from . import Mapped, Model, relationship, sa, sa_orm from .membership_mixin import ( FrozenAttributionProtoMixin, ImmutableUserMembershipMixin, @@ -80,31 +80,25 @@ class ProjectSponsorMembership( # type: ignore[misc] }, } - revoke_on_member_delete = False + revoke_on_member_delete: ClassVar[bool] = False - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False ) - project: Mapped[Project] = relationship( - Project, - backref=backref( - 'all_sponsor_memberships', - lazy='dynamic', - cascade='all', - passive_deletes=True, - ), - ) - parent_id: Mapped[int] = sa.orm.synonym('project_id') + project: Mapped[Project] = relationship(back_populates='all_sponsor_memberships') + parent_id: Mapped[int] = sa_orm.synonym('project_id') parent_id_column = 'project_id' - parent: Mapped[Project] = sa.orm.synonym('project') + parent: Mapped[Project] = sa_orm.synonym('project') #: Is this sponsor being promoted for commercial reasons? Projects may have a legal #: obligation to reveal this. This column records a declaration from the project. - is_promoted = immutable(sa.orm.mapped_column(sa.Boolean, nullable=False)) + is_promoted: Mapped[bool] = immutable( + sa_orm.mapped_column(sa.Boolean, nullable=False) + ) #: Optional label, indicating the type of sponsor - label = immutable( - sa.orm.mapped_column( + label: Mapped[str | None] = immutable( + sa_orm.mapped_column( sa.Unicode, sa.CheckConstraint( "label <> ''", name='project_sponsor_membership_label_check' @@ -124,30 +118,6 @@ def offered_roles(self) -> set[str]: return set() -@reopen(Project) -class __Project: - sponsor_memberships: DynamicMapped[ProjectSponsorMembership] = with_roles( - relationship( - ProjectSponsorMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectSponsorMembership.project_id == Project.id, - ProjectSponsorMembership.is_active, - ), - order_by=ProjectSponsorMembership.seq, - viewonly=True, - ), - read={'all'}, - ) - - @with_roles(read={'all'}) - @cached_property - def has_sponsors(self) -> bool: - return db.session.query(self.sponsor_memberships.exists()).scalar() - - sponsors = DynamicAssociationProxy('sponsor_memberships', 'member') - - # FIXME: Replace this with existing proposal collaborator as they're now both related # to "account" class ProposalSponsorMembership( # type: ignore[misc] @@ -210,31 +180,25 @@ class ProposalSponsorMembership( # type: ignore[misc] }, } - revoke_on_member_delete = False + revoke_on_member_delete: ClassVar[bool] = False - proposal_id: Mapped[int] = sa.orm.mapped_column( + proposal_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False ) - proposal: Mapped[Proposal] = relationship( - Proposal, - backref=backref( - 'all_sponsor_memberships', - lazy='dynamic', - cascade='all', - passive_deletes=True, - ), - ) - parent_id: Mapped[int] = sa.orm.synonym('proposal_id') + proposal: Mapped[Proposal] = relationship(back_populates='all_sponsor_memberships') + parent_id: Mapped[int] = sa_orm.synonym('proposal_id') parent_id_column = 'proposal_id' - parent: Mapped[Proposal] = sa.orm.synonym('proposal') + parent: Mapped[Proposal] = sa_orm.synonym('proposal') #: Is this sponsor being promoted for commercial reasons? Proposals may have a legal #: obligation to reveal this. This column records a declaration from the proposal. - is_promoted = immutable(sa.orm.mapped_column(sa.Boolean, nullable=False)) + is_promoted: Mapped[bool] = immutable( + sa_orm.mapped_column(sa.Boolean, nullable=False) + ) #: Optional label, indicating the type of sponsor - label = immutable( - sa.orm.mapped_column( + label: Mapped[str | None] = immutable( + sa_orm.mapped_column( sa.Unicode, sa.CheckConstraint( "label <> ''", name='proposal_sponsor_membership_label_check' @@ -247,131 +211,3 @@ class ProposalSponsorMembership( # type: ignore[misc] def offered_roles(self) -> set[str]: """Return empty set as this membership does not offer any roles on Proposal.""" return set() - - -@reopen(Proposal) -class __Proposal: - sponsor_memberships: DynamicMapped[ProposalSponsorMembership] = with_roles( - relationship( - ProposalSponsorMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProposalSponsorMembership.proposal_id == Proposal.id, - ProposalSponsorMembership.is_active, - ), - order_by=ProposalSponsorMembership.seq, - viewonly=True, - ), - read={'all'}, - ) - - @with_roles(read={'all'}) - @cached_property - def has_sponsors(self) -> bool: - return db.session.query(self.sponsor_memberships.exists()).scalar() - - sponsors = DynamicAssociationProxy('sponsor_memberships', 'member') - - -@reopen(Account) -class __Account: - # pylint: disable=invalid-unary-operand-type - noninvite_project_sponsor_memberships: DynamicMapped[ - ProjectSponsorMembership - ] = relationship( - ProjectSponsorMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectSponsorMembership.member_id == Account.id, - ~ProjectSponsorMembership.is_invite, - ), - order_by=ProjectSponsorMembership.granted_at.desc(), - viewonly=True, - ) - - project_sponsor_memberships: DynamicMapped[ProjectSponsorMembership] = relationship( - ProjectSponsorMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectSponsorMembership.member_id == Account.id, - ProjectSponsorMembership.is_active, - ), - order_by=ProjectSponsorMembership.granted_at.desc(), - viewonly=True, - ) - - project_sponsor_membership_invites: DynamicMapped[ - ProjectSponsorMembership - ] = with_roles( - relationship( - ProjectSponsorMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProjectSponsorMembership.member_id == Account.id, - ProjectSponsorMembership.is_invite, - ProjectSponsorMembership.revoked_at.is_(None), - ), - order_by=ProjectSponsorMembership.granted_at.desc(), - viewonly=True, - ), - read={'admin'}, - ) - - noninvite_proposal_sponsor_memberships: DynamicMapped[ - ProposalSponsorMembership - ] = relationship( - ProposalSponsorMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProposalSponsorMembership.member_id == Account.id, - ~ProposalSponsorMembership.is_invite, - ), - order_by=ProposalSponsorMembership.granted_at.desc(), - viewonly=True, - ) - - proposal_sponsor_memberships: DynamicMapped[ - ProposalSponsorMembership - ] = relationship( - ProposalSponsorMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProposalSponsorMembership.member_id == Account.id, - ProposalSponsorMembership.is_active, - ), - order_by=ProposalSponsorMembership.granted_at.desc(), - viewonly=True, - ) - - proposal_sponsor_membership_invites: DynamicMapped[ - ProposalSponsorMembership - ] = with_roles( - relationship( - ProposalSponsorMembership, - lazy='dynamic', - primaryjoin=sa.and_( - ProposalSponsorMembership.member_id == Account.id, - ProposalSponsorMembership.is_invite, - ProposalSponsorMembership.revoked_at.is_(None), - ), - order_by=ProposalSponsorMembership.granted_at.desc(), - viewonly=True, - ), - read={'admin'}, - ) - - sponsored_projects = DynamicAssociationProxy( - 'project_sponsor_memberships', 'project' - ) - - sponsored_proposals = DynamicAssociationProxy( - 'proposal_sponsor_memberships', 'proposal' - ) - - -Account.__active_membership_attrs__.update( - {'project_sponsor_memberships', 'proposal_sponsor_memberships'} -) -Account.__noninvite_membership_attrs__.update( - {'noninvite_project_sponsor_memberships', 'noninvite_proposal_sponsor_memberships'} -) diff --git a/funnel/models/sync_ticket.py b/funnel/models/sync_ticket.py index cef344296..19969fed4 100644 --- a/funnel/models/sync_ticket.py +++ b/funnel/models/sync_ticket.py @@ -5,9 +5,9 @@ import base64 import os from collections.abc import Iterable, Sequence -from typing import Any +from typing import Any, Self -from coaster.sqlalchemy import LazyRoleSet +from coaster.sqlalchemy import LazyRoleSet, with_roles from . import ( BaseMixin, @@ -15,17 +15,14 @@ DynamicMapped, Mapped, Model, - Query, UuidMixin, - backref, db, relationship, sa, - with_roles, + sa_orm, ) from .account import Account, AccountEmail -from .email_address import EmailAddress, EmailAddressMixin -from .helpers import reopen +from .email_address import EmailAddress, OptionalEmailAddressMixin from .project import Project from .project_membership import project_child_role_map @@ -76,7 +73,7 @@ class GetTitleMixin(BaseScopedNameMixin): @classmethod def get( cls, parent: Any, name: str | None = None, title: str | None = None - ) -> GetTitleMixin | None: + ) -> Self | None: if not bool(name) ^ bool(title): raise TypeError("Expects name xor title") if name: @@ -90,7 +87,7 @@ def upsert( # type: ignore[override] # pylint: disable=arguments-renamed current_name: str | None = None, current_title: str | None = None, **fields, - ) -> GetTitleMixin: + ) -> Self: instance = cls.get(parent, current_name, current_title) if instance is not None: instance._set_fields(fields) # pylint: disable=protected-access @@ -114,34 +111,35 @@ class TicketEvent(GetTitleMixin, Model): __tablename__ = 'ticket_event' - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( - relationship(Project, backref=backref('ticket_events', cascade='all')), + relationship(back_populates='ticket_events'), rw={'project_promoter'}, grants_via={None: project_child_role_map}, ) - parent: Mapped[Project] = sa.orm.synonym('project') + parent: Mapped[Project] = sa_orm.synonym('project') ticket_types: Mapped[list[TicketType]] = with_roles( relationship( - 'TicketType', - secondary=ticket_event_ticket_type, - back_populates='ticket_events', + secondary=ticket_event_ticket_type, back_populates='ticket_events' ), rw={'project_promoter'}, ) ticket_participants: DynamicMapped[TicketParticipant] = with_roles( relationship( - 'TicketParticipant', secondary='ticket_event_participant', - backref='ticket_events', lazy='dynamic', + back_populates='ticket_events', ), rw={'project_promoter'}, ) badge_template: Mapped[str | None] = with_roles( - sa.orm.mapped_column(sa.Unicode(250), nullable=True), rw={'project_promoter'} + sa_orm.mapped_column(sa.Unicode(250), nullable=True), rw={'project_promoter'} + ) + + ticket_event_participants: Mapped[list[TicketEventParticipant]] = relationship( + back_populates='ticket_event', overlaps='ticket_events,ticket_participants' ) __table_args__ = ( @@ -172,24 +170,22 @@ class TicketType(GetTitleMixin, Model): __tablename__ = 'ticket_type' - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( - relationship(Project, backref=backref('ticket_types', cascade='all')), + relationship(back_populates='ticket_types'), rw={'project_promoter'}, grants_via={None: project_child_role_map}, ) - parent: Mapped[Project] = sa.orm.synonym('project') + parent: Mapped[Project] = sa_orm.synonym('project') ticket_events: Mapped[list[TicketEvent]] = with_roles( - relationship( - TicketEvent, - secondary=ticket_event_ticket_type, - back_populates='ticket_types', - ), + relationship(secondary=ticket_event_ticket_type, back_populates='ticket_types'), rw={'project_promoter'}, ) + sync_tickets: Mapped[list[SyncTicket]] = relationship(back_populates='ticket_type') + __table_args__ = ( sa.UniqueConstraint('project_id', 'name'), sa.UniqueConstraint('project_id', 'title'), @@ -206,59 +202,58 @@ class TicketType(GetTitleMixin, Model): } -class TicketParticipant(EmailAddressMixin, UuidMixin, BaseMixin, Model): +class TicketParticipant(OptionalEmailAddressMixin, UuidMixin, BaseMixin, Model): """A participant in one or more events, synced from an external ticket source.""" __tablename__ = 'ticket_participant' - __email_optional__ = True __email_for__ = 'participant' fullname: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=False), + sa_orm.mapped_column(sa.Unicode(80), nullable=False), read={'promoter', 'member', 'scanner'}, ) #: Unvalidated phone number phone: Mapped[str | None] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=True), + sa_orm.mapped_column(sa.Unicode(80), nullable=True), read={'promoter', 'member', 'scanner'}, ) #: Unvalidated Twitter id twitter: Mapped[str | None] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=True), + sa_orm.mapped_column(sa.Unicode(80), nullable=True), read={'promoter', 'member', 'scanner'}, ) #: Job title job_title: Mapped[str | None] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=True), + sa_orm.mapped_column(sa.Unicode(80), nullable=True), read={'promoter', 'member', 'scanner'}, ) #: Company company: Mapped[str | None] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=True), + sa_orm.mapped_column(sa.Unicode(80), nullable=True), read={'promoter', 'member', 'scanner'}, ) #: Participant's city city: Mapped[str | None] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=True), + sa_orm.mapped_column(sa.Unicode(80), nullable=True), read={'promoter', 'member', 'scanner'}, ) # public key - puk: Mapped[str] = sa.orm.mapped_column( + puk: Mapped[str] = sa_orm.mapped_column( sa.Unicode(44), nullable=False, default=make_public_key, unique=True ) - key: Mapped[str] = sa.orm.mapped_column( + key: Mapped[str] = sa_orm.mapped_column( sa.Unicode(44), nullable=False, default=make_private_key, unique=True ) - badge_printed: Mapped[bool] = sa.orm.mapped_column( + badge_printed: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, default=False, nullable=False ) - participant_id: Mapped[int | None] = sa.orm.mapped_column( + participant_id: Mapped[int | None] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=True ) participant: Mapped[Account | None] = relationship( - Account, backref=backref('ticket_participants', cascade='all') + back_populates='ticket_participants' ) - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( @@ -267,6 +262,21 @@ class TicketParticipant(EmailAddressMixin, UuidMixin, BaseMixin, Model): grants_via={None: project_child_role_map}, ) + scanned_contacts: Mapped[ContactExchange] = relationship( + passive_deletes=True, back_populates='ticket_participant' + ) + + ticket_events: Mapped[list[TicketEvent]] = relationship( + secondary='ticket_event_participant', back_populates='ticket_participants' + ) + ticket_event_participants: Mapped[list[TicketEventParticipant]] = relationship( + overlaps='ticket_events,ticket_participants', + back_populates='ticket_participant', + ) + sync_tickets: Mapped[list[SyncTicket]] = relationship( + back_populates='ticket_participant' + ) + __table_args__ = (sa.UniqueConstraint('project_id', 'email_address_id'),) # Since 'email' comes from the mixin, it's not available to be annotated using @@ -398,31 +408,21 @@ class TicketEventParticipant(BaseMixin, Model): __tablename__ = 'ticket_event_participant' - ticket_participant_id: Mapped[int] = sa.orm.mapped_column( + ticket_participant_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_participant.id'), nullable=False ) ticket_participant: Mapped[TicketParticipant] = relationship( - TicketParticipant, - backref=backref( - 'ticket_event_participants', - cascade='all', - overlaps='ticket_events,ticket_participants', - ), + back_populates='ticket_event_participants', overlaps='ticket_events,ticket_participants', ) - ticket_event_id: Mapped[int] = sa.orm.mapped_column( + ticket_event_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_event.id'), nullable=False ) ticket_event: Mapped[TicketEvent] = relationship( - TicketEvent, - backref=backref( - 'ticket_event_participants', - cascade='all', - overlaps='ticket_events,ticket_participants', - ), + back_populates='ticket_event_participants', overlaps='ticket_events,ticket_participants', ) - checked_in: Mapped[bool] = sa.orm.mapped_column( + checked_in: Mapped[bool] = sa_orm.mapped_column( sa.Boolean, default=False, nullable=False ) @@ -453,29 +453,33 @@ def get( class TicketClient(BaseMixin, Model): __tablename__ = 'ticket_client' name: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa_orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) client_eventid: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa_orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) clientid: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa_orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) client_secret: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa_orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) client_access_token: Mapped[str] = with_roles( - sa.orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} + sa_orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( - relationship(Project, backref=backref('ticket_clients', cascade='all')), + relationship(back_populates='ticket_clients'), rw={'project_promoter'}, grants_via={None: project_child_role_map}, ) + sync_tickets: Mapped[list[SyncTicket]] = relationship( + back_populates='ticket_client' + ) + __roles__ = {'all': {'call': {'url_for'}}} def import_from_list(self, ticket_list): @@ -524,27 +528,22 @@ class SyncTicket(BaseMixin, Model): __tablename__ = 'sync_ticket' - ticket_no: Mapped[str] = sa.orm.mapped_column(sa.Unicode(80), nullable=False) - order_no: Mapped[str] = sa.orm.mapped_column(sa.Unicode(80), nullable=False) - ticket_type_id: Mapped[int] = sa.orm.mapped_column( + ticket_no: Mapped[str] = sa_orm.mapped_column(sa.Unicode(80), nullable=False) + order_no: Mapped[str] = sa_orm.mapped_column(sa.Unicode(80), nullable=False) + ticket_type_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_type.id'), nullable=False ) - ticket_type: Mapped[TicketType] = relationship( - TicketType, backref=backref('sync_tickets', cascade='all') - ) - ticket_participant_id: Mapped[int] = sa.orm.mapped_column( + ticket_type: Mapped[TicketType] = relationship(back_populates='sync_tickets') + ticket_participant_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_participant.id'), nullable=False ) ticket_participant: Mapped[TicketParticipant] = relationship( - TicketParticipant, - backref=backref('sync_tickets', cascade='all'), + back_populates='sync_tickets' ) - ticket_client_id: Mapped[int] = sa.orm.mapped_column( + ticket_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('ticket_client.id'), nullable=False ) - ticket_client: Mapped[TicketClient] = relationship( - TicketClient, backref=backref('sync_tickets', cascade='all') - ) + ticket_client: Mapped[TicketClient] = relationship(back_populates='sync_tickets') __table_args__ = (sa.UniqueConstraint('ticket_client_id', 'order_no', 'ticket_no'),) @classmethod @@ -584,42 +583,5 @@ def upsert( return ticket -@reopen(Project) -class __Project: - # XXX: This relationship exposes an edge case in RoleMixin. It previously expected - # TicketParticipant.participant to be unique per project, meaning one user could - # have one participant ticket only. This is not guaranteed by the model as tickets - # are unique per email address per ticket type, and one user can have (a) two email - # addresses with tickets, or (b) tickets of different types. RoleMixin has since - # been patched to look for the first matching record (.first() instead of .one()). - # This may expose a new edge case in future in case the TicketParticipant model adds - # an `offered_roles` method, as only the first matching record's method will be - # called - ticket_participants: DynamicMapped[TicketParticipant] = with_roles( - relationship( - TicketParticipant, lazy='dynamic', cascade='all', back_populates='project' - ), - grants_via={ - 'participant': {'participant', 'project_participant', 'ticket_participant'} - }, - ) - - -@reopen(Account) -class __Account: - @property - def ticket_followers(self) -> Query[Account]: - """All users with a ticket in a project.""" - return ( - Account.query.filter(Account.state.ACTIVE) - .join(TicketParticipant, TicketParticipant.participant_id == Account.id) - .join(Project, TicketParticipant.project_id == Project.id) - .filter(Project.state.PUBLISHED, Project.account == self) - ) - - with_roles(ticket_followers, grants={'follower'}) - - -# Tail imports to avoid cyclic dependency errors, for symbols used only in methods -# pylint: disable=wrong-import-position -from .contact_exchange import ContactExchange # isort:skip +# Tail imports +from .contact_exchange import ContactExchange diff --git a/funnel/models/types.py b/funnel/models/types.py index dfd6abe64..95f9c5cf1 100644 --- a/funnel/models/types.py +++ b/funnel/models/types.py @@ -3,6 +3,7 @@ from typing import Annotated, TypeAlias import sqlalchemy as sa +import sqlalchemy.orm as sa_orm from sqlalchemy.dialects import postgresql from sqlalchemy.orm import mapped_column from sqlalchemy_json import mutable_json_type @@ -37,7 +38,7 @@ text: TypeAlias = Annotated[str, mapped_column(sa.UnicodeText())] jsonb: TypeAlias = Annotated[ dict, - sa.orm.mapped_column( + sa_orm.mapped_column( # FIXME: mutable_json_type assumes `dict|list`, not just `dict` mutable_json_type( dbtype=sa.JSON().with_variant(postgresql.JSONB, 'postgresql'), nested=True @@ -46,7 +47,7 @@ ] jsonb_dict: TypeAlias = Annotated[ dict, - sa.orm.mapped_column( + sa_orm.mapped_column( # FIXME: mutable_json_type assumes `dict|list`, not just `dict` mutable_json_type( dbtype=sa.JSON().with_variant(postgresql.JSONB, 'postgresql'), nested=True diff --git a/funnel/models/update.py b/funnel/models/update.py index f579a386f..266dccc34 100644 --- a/funnel/models/update.py +++ b/funnel/models/update.py @@ -4,8 +4,7 @@ from collections.abc import Sequence from datetime import datetime - -from sqlalchemy.orm import Query as BaseQuery +from typing import Self from baseframe import __ from coaster.sqlalchemy import LazyRoleSet, StateManager, auto_init_default, with_roles @@ -18,17 +17,16 @@ Query, TSVectorType, UuidMixin, - backref, db, relationship, sa, + sa_orm, ) from .account import Account from .comment import SET_TYPE, Commentset from .helpers import ( MarkdownCompositeDocument, add_search_trigger, - reopen, visual_field_delimiter, ) from .project import Project @@ -50,7 +48,7 @@ class VISIBILITY_STATE(LabeledEnum): # noqa: N801 class Update(UuidMixin, BaseScopedIdNameMixin, Model): __tablename__ = 'update' - _visibility_state: Mapped[int] = sa.orm.mapped_column( + _visibility_state: Mapped[int] = sa_orm.mapped_column( 'visibility_state', sa.SmallInteger, StateManager.check_constraint('visibility_state', VISIBILITY_STATE), @@ -58,11 +56,11 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): nullable=False, index=True, ) - visibility_state = StateManager( + visibility_state = StateManager['Update']( '_visibility_state', VISIBILITY_STATE, doc="Visibility state" ) - _state: Mapped[int] = sa.orm.mapped_column( + _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.SmallInteger, StateManager.check_constraint('state', UPDATE_STATE), @@ -70,26 +68,25 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): nullable=False, index=True, ) - state = StateManager('_state', UPDATE_STATE, doc="Update state") + state = StateManager['Update']('_state', UPDATE_STATE, doc="Update state") - created_by_id: Mapped[int] = sa.orm.mapped_column( + created_by_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False, index=True ) created_by: Mapped[Account] = with_roles( relationship( - Account, - backref=backref('updates_created', lazy='dynamic'), + back_populates='created_updates', foreign_keys=[created_by_id], ), read={'all'}, grants={'creator'}, ) - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False, index=True ) project: Mapped[Project] = with_roles( - relationship(Project, backref=backref('updates', lazy='dynamic')), + relationship(back_populates='updates'), read={'all'}, datasets={'primary'}, grants_via={ @@ -100,7 +97,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): } }, ) - parent: Mapped[Project] = sa.orm.synonym('project') + 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 @@ -111,7 +108,6 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): # redefined in a subclass _project_when_unrestricted: Mapped[Project] = with_roles( relationship( - Project, viewonly=True, uselist=False, primaryjoin=sa.and_( @@ -127,66 +123,56 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): #: Update number, for Project updates, assigned when the update is published number: Mapped[int | None] = with_roles( - sa.orm.mapped_column(sa.Integer, nullable=True, default=None), read={'all'} + sa_orm.mapped_column(sa.Integer, nullable=True, default=None), read={'all'} ) #: Like pinned tweets. You can keep posting updates, #: but might want to pin an update from a week ago. is_pinned: Mapped[bool] = with_roles( - sa.orm.mapped_column(sa.Boolean, default=False, nullable=False), read={'all'} + sa_orm.mapped_column(sa.Boolean, default=False, nullable=False), read={'all'} ) - published_by_id: Mapped[int | None] = sa.orm.mapped_column( + published_by_id: Mapped[int | None] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=True, index=True ) published_by: Mapped[Account | None] = with_roles( relationship( - Account, - backref=backref('published_updates', lazy='dynamic'), + back_populates='published_updates', foreign_keys=[published_by_id], ), read={'all'}, ) published_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'} + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'} ) - deleted_by_id: Mapped[int | None] = sa.orm.mapped_column( + deleted_by_id: Mapped[int | None] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=True, index=True ) deleted_by: Mapped[Account | None] = with_roles( - relationship( - Account, - backref=backref('deleted_updates', lazy='dynamic'), - foreign_keys=[deleted_by_id], - ), + relationship(back_populates='deleted_updates', foreign_keys=[deleted_by_id]), read={'reader'}, ) deleted_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'reader'}, ) edited_at: Mapped[datetime | None] = with_roles( - sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'} + sa_orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True), read={'all'} ) - commentset_id: Mapped[int] = sa.orm.mapped_column( + commentset_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id'), nullable=False ) commentset: Mapped[Commentset] = with_roles( relationship( - Commentset, - uselist=False, - lazy='joined', - cascade='all', - single_parent=True, - backref=backref('update', uselist=False), + uselist=False, lazy='joined', single_parent=True, back_populates='update' ), read={'all'}, ) - search_vector: Mapped[str] = sa.orm.mapped_column( + search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( 'name', 'title', @@ -365,7 +351,7 @@ def roles_for( return roles @classmethod - def all_published_public(cls) -> Query[Update]: + def all_published_public(cls) -> Query[Self]: return cls.query.join(Project).filter( Project.state.PUBLISHED, cls.state.PUBLISHED, cls.visibility_state.PUBLIC ) @@ -404,32 +390,3 @@ def getprev(self) -> Update | None: add_search_trigger(Update, 'search_vector') auto_init_default(Update._visibility_state) # pylint: disable=protected-access auto_init_default(Update._state) # pylint: disable=protected-access - - -@reopen(Project) -class __Project: - updates: BaseQuery - - @property - def published_updates(self) -> BaseQuery: - return self.updates.filter(Update.state.PUBLISHED).order_by( - Update.is_pinned.desc(), Update.published_at.desc() - ) - - with_roles(published_updates, read={'all'}) - - @property - def draft_updates(self) -> BaseQuery: - return self.updates.filter(Update.state.DRAFT).order_by(Update.created_at) - - with_roles(draft_updates, read={'editor'}) - - @property - def pinned_update(self) -> Update | None: - return ( - self.updates.filter(Update.state.PUBLISHED, Update.is_pinned.is_(True)) - .order_by(Update.published_at.desc()) - .first() - ) - - with_roles(pinned_update, read={'all'}) diff --git a/funnel/models/venue.py b/funnel/models/venue.py index 585dbd07b..b57450922 100644 --- a/funnel/models/venue.py +++ b/funnel/models/venue.py @@ -2,7 +2,7 @@ from __future__ import annotations -from sqlalchemy.ext.orderinglist import ordering_list +from sqlalchemy.ext.orderinglist import OrderingList, ordering_list from coaster.sqlalchemy import add_primary_relationship, with_roles @@ -14,8 +14,9 @@ UuidMixin, relationship, sa, + sa_orm, ) -from .helpers import MarkdownCompositeBasic, reopen +from .helpers import MarkdownCompositeBasic from .project import Project from .project_membership import project_child_role_map, project_child_role_set @@ -25,43 +26,40 @@ class Venue(UuidMixin, BaseScopedNameMixin, CoordinatesMixin, Model): __tablename__ = 'venue' - project_id: Mapped[int] = sa.orm.mapped_column( + project_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('project.id'), nullable=False ) project: Mapped[Project] = with_roles( - relationship(Project, back_populates='venues'), - grants_via={None: project_child_role_map}, + relationship(back_populates='venues'), grants_via={None: project_child_role_map} ) - parent: Mapped[Project] = sa.orm.synonym('project') + parent: Mapped[Project] = sa_orm.synonym('project') description, description_text, description_html = MarkdownCompositeBasic.create( 'description', default='', nullable=False ) - address1: Mapped[str] = sa.orm.mapped_column( + address1: Mapped[str] = sa_orm.mapped_column( sa.Unicode(160), default='', nullable=False ) - address2: Mapped[str] = sa.orm.mapped_column( + address2: Mapped[str] = sa_orm.mapped_column( sa.Unicode(160), default='', nullable=False ) - city: Mapped[str] = sa.orm.mapped_column(sa.Unicode(30), default='', nullable=False) - state: Mapped[str] = sa.orm.mapped_column( + city: Mapped[str] = sa_orm.mapped_column(sa.Unicode(30), default='', nullable=False) + state: Mapped[str] = sa_orm.mapped_column( sa.Unicode(30), default='', nullable=False ) - postcode: Mapped[str] = sa.orm.mapped_column( + postcode: Mapped[str] = sa_orm.mapped_column( sa.Unicode(20), default='', nullable=False ) - country: Mapped[str] = sa.orm.mapped_column( + country: Mapped[str] = sa_orm.mapped_column( sa.Unicode(2), default='', nullable=False ) - rooms: Mapped[list[VenueRoom]] = relationship( - 'VenueRoom', - cascade='all', - order_by='VenueRoom.seq', + rooms: Mapped[OrderingList[VenueRoom]] = relationship( + order_by=lambda: VenueRoom.seq, collection_class=ordering_list('seq', count_from=1), back_populates='venue', ) - seq: Mapped[int] = sa.orm.mapped_column(sa.Integer, nullable=False) + seq: Mapped[int] = sa_orm.mapped_column(sa.Integer, nullable=False) __table_args__ = (sa.UniqueConstraint('project_id', 'name'),) @@ -115,23 +113,31 @@ class Venue(UuidMixin, BaseScopedNameMixin, CoordinatesMixin, Model): class VenueRoom(UuidMixin, BaseScopedNameMixin, Model): __tablename__ = 'venue_room' - venue_id: Mapped[int] = sa.orm.mapped_column( + venue_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('venue.id'), nullable=False ) venue: Mapped[Venue] = with_roles( - relationship(Venue, back_populates='rooms'), + relationship(back_populates='rooms'), # Since Venue already remaps Project roles, we just want the remapped role names grants_via={None: project_child_role_set}, ) - parent: Mapped[Venue] = sa.orm.synonym('venue') + parent: Mapped[Venue] = sa_orm.synonym('venue') description, description_text, description_html = MarkdownCompositeBasic.create( 'description', default='', nullable=False ) - bgcolor: Mapped[str] = sa.orm.mapped_column( + bgcolor: Mapped[str] = sa_orm.mapped_column( sa.Unicode(6), nullable=False, default='229922' ) - seq: Mapped[int] = sa.orm.mapped_column(sa.Integer, nullable=False) + seq: Mapped[int] = sa_orm.mapped_column(sa.Integer, nullable=False) + + sessions: Mapped[list[Session]] = relationship(back_populates='venue_room') + scheduled_sessions: Mapped[list[Session]] = relationship( + primaryjoin=lambda: sa.and_( + Session.venue_room_id == VenueRoom.id, Session.scheduled + ), + viewonly=True, + ) __table_args__ = (sa.UniqueConstraint('venue_id', 'name'),) @@ -184,19 +190,5 @@ def scoped_name(self): with_roles(Project.primary_venue, read={'all'}, datasets={'primary', 'without_parent'}) -@reopen(Project) -class __Project: - venues: Mapped[list[Venue]] = with_roles( - relationship( - Venue, - cascade='all', - order_by='Venue.seq', - collection_class=ordering_list('seq', count_from=1), - back_populates='project', - ), - read={'all'}, - ) - - @property - def rooms(self): - return [room for venue in self.venues for room in venue.rooms] +# Tail imports +from .session import Session diff --git a/funnel/models/video_mixin.py b/funnel/models/video_mixin.py index 1775d2490..e8ceab14a 100644 --- a/funnel/models/video_mixin.py +++ b/funnel/models/video_mixin.py @@ -4,7 +4,7 @@ from furl import furl -from . import Mapped, declarative_mixin, sa +from . import Mapped, declarative_mixin, sa, sa_orm __all__ = ['VideoMixin', 'VideoError', 'parse_video_url'] @@ -75,8 +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( + 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 ) diff --git a/funnel/proxies/request.py b/funnel/proxies/request.py index c709ebb7a..38c66548d 100644 --- a/funnel/proxies/request.py +++ b/funnel/proxies/request.py @@ -4,9 +4,10 @@ from collections.abc import Callable from functools import wraps -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from flask import has_request_context, request +from flask.globals import request_ctx from werkzeug.local import LocalProxy from werkzeug.utils import cached_property @@ -121,25 +122,23 @@ def hx_prompt(self) -> str | None: def _get_current_object(self) -> RequestWants: """Type hint for the LocalProxy wrapper method.""" + return self def _get_request_wants() -> RequestWants: """Get request_wants from the request.""" - # Flask 2.0 deprecated use of _request_ctx_stack.top and recommends using `g`. - # However, `g` is not suitable for us as we must cache results for a request only. - # Therefore we stick it in the request object itself. if has_request_context(): # pylint: disable=protected-access - wants = getattr(request, '_request_wants', None) + wants = getattr(request_ctx, 'request_wants', None) if wants is None: wants = RequestWants() - request._request_wants = wants # type: ignore[attr-defined] + request_ctx.request_wants = wants # type: ignore[attr-defined] return wants # Return an empty handler return RequestWants() -request_wants: RequestWants = LocalProxy(_get_request_wants) # type: ignore[assignment] +request_wants: RequestWants = cast(RequestWants, LocalProxy(_get_request_wants)) def response_varies(response: ResponseType) -> ResponseType: diff --git a/funnel/templates/account_menu.html.jinja2 b/funnel/templates/account_menu.html.jinja2 index fcaf35c39..039d88321 100644 --- a/funnel/templates/account_menu.html.jinja2 +++ b/funnel/templates/account_menu.html.jinja2 @@ -27,13 +27,32 @@ class="header__dropdown__item header__dropdown__item--flex mui--text-dark nounderline mui--text-subhead mui--text-light">{{ faicon(icon='plus', icon_size='title', baseline=false, css_class="mui--text-light") }}{% trans %}Add username{% endtrans %} {%- endif %} - {%- with orgmemlist = current_auth.user.views.recent_organization_memberships() %} - {%- if orgmemlist.recent|length -%} +
  • + {{ faicon(icon='sitemap', icon_size='title', baseline=false, css_class="mui--text-light") }}{% trans %}Organizations{% endtrans %} +
  • + {%- with orglist = current_auth.user.views.organizations_as_member %} + {%- for org in orglist %}
  • - {{ faicon(icon='sitemap', icon_size='title', baseline=false, css_class="mui--text-light") }}{% trans %}Organizations{% endtrans %} + + + {%- if org.logo_url.url %} + {{ org.title }} + {% else %} + {{ org.title }} + {% endif %} + + {{ org.title }}{{ faicon(icon='crown-solid', baseline=true, icon_size='body2', css_class="mui--text-success fa-icon--right-margin") }}{% trans %}Member{% endtrans %} +
  • + {%- endfor %} + {%- endwith %} + {%- with orgmemlist = current_auth.user.views.recent_organization_memberships() %} + {%- if orgmemlist.recent|length -%} {%- for orgmem in orgmemlist.recent %}
  • {{ faicon(icon='bell', icon_size='subhead', baseline=false, css_class="mui--text-light") }}{% trans %}Notification settings{% endtrans %}
  • - {%- if not orgmemlist.recent|length -%} -
  • - {{ faicon(icon='sitemap', icon_size='subhead', baseline=false, css_class="mui--text-light") }}{% trans %}Organizations{% endtrans %} -
  • - {%- endif %} {%- endwith %}
  • {% for account in featured_accounts %}
  • - {{ profilecard(account) }} + {%- if account.current_roles.member %} + {{ profilecard(account, snippet_html=false, is_member=true) }} + {%- else %} + {{ profilecard(account) }} + {% endif %}
  • {%- endfor -%} diff --git a/funnel/templates/macros.html.jinja2 b/funnel/templates/macros.html.jinja2 index a2ef2faa6..4191a8af4 100644 --- a/funnel/templates/macros.html.jinja2 +++ b/funnel/templates/macros.html.jinja2 @@ -446,7 +446,7 @@ {%- endif %} {%- endmacro %} -{% macro profilecard(account, snippet_html) %} +{% macro profilecard(account, snippet_html, is_member) %}
    @@ -465,6 +465,9 @@ {%- if snippet_html %}

    {{ faicon(icon='search', css_class="search-icon", baseline=false) }} {{ snippet_html }}

    {% endif %} + {%- if is_member %} +
    {{ faicon(icon='crown-solid', baseline=true, css_class="mui--text-success fa-icon--right-margin") }}{% trans %}Member{% endtrans %}
    + {% endif %}

    {% trans tcount=account.published_project_count, count=account.published_project_count|numberformat %}One project{% pluralize tcount %}{{ count }} projects{% endtrans %}

    {% trans %}Explore{% endtrans %} diff --git a/funnel/templates/project_layout.html.jinja2 b/funnel/templates/project_layout.html.jinja2 index 3c7aedaec..b2992d663 100644 --- a/funnel/templates/project_layout.html.jinja2 +++ b/funnel/templates/project_layout.html.jinja2 @@ -51,7 +51,7 @@ {% macro project_header(project) %} {%- if project.livestream_urls %} - {% if (project.is_restricted_video and project.current_roles.participant) or not project.is_restricted_video %} + {% if (project.is_restricted_video and project.current_roles.account_member) or not project.is_restricted_video %}
    {% if project.livestream_urls|length >= 2 %}
      @@ -125,7 +125,7 @@ {% endif %} {{ project.account.title }} - {% if project.features.subscription and project.current_roles.participant %} + {% if project.features.subscription and project.current_roles.account_member %} {{ faicon(icon='crown-solid', baseline=true, css_class="mui--text-success fa-icon--right-margin") }}{% trans %}Member{% endtrans %} {% elif project.features.subscription %} {{ faicon(icon='lock-alt', baseline=true, css_class="fa-icon--right-margin") }}{% trans %}For members{% endtrans %} diff --git a/funnel/transports/base.py b/funnel/transports/base.py index 22eb85d2a..5eb495e40 100644 --- a/funnel/transports/base.py +++ b/funnel/transports/base.py @@ -17,7 +17,7 @@ } -def init(): +def init() -> None: if app.config.get('MAIL_SERVER'): platform_transports['email'] = True if sms_init(): diff --git a/funnel/transports/email/send.py b/funnel/transports/email/send.py index a2de80964..14e38a2e0 100644 --- a/funnel/transports/email/send.py +++ b/funnel/transports/email/send.py @@ -91,13 +91,11 @@ def jsonld_event_reservation(rsvp: Rsvp) -> dict[str, object]: 'addressCountry': venue.country, } location['address'] = postal_address - event_mode = "https://schema.org/OfflineEventAttendanceMode" else: location = { "@type": "VirtualLocation", "url": rsvp.project.absolute_url, } - event_mode = "https://schema.org/OnlineEventAttendanceMode" return { '@context': 'https://schema.org', '@type': 'EventReservation', @@ -109,7 +107,6 @@ def jsonld_event_reservation(rsvp: Rsvp) -> dict[str, object]: if rsvp.state.NO else 'https://schema.org/ReservationPending' ), - "eventAttendanceMode": event_mode, 'underName': { '@type': 'Person', 'name': rsvp.participant.fullname, @@ -127,6 +124,7 @@ def jsonld_event_reservation(rsvp: Rsvp) -> dict[str, object]: }, }, 'modifyReservationUrl': rsvp.project.absolute_url, + 'modifiedTime': rsvp.updated_at, 'numSeats': '1', } diff --git a/funnel/transports/sms/template.py b/funnel/transports/sms/template.py index 65b0d55bf..a9ae13af5 100644 --- a/funnel/transports/sms/template.py +++ b/funnel/transports/sms/template.py @@ -6,6 +6,7 @@ from enum import Enum from re import Pattern from string import Formatter +from types import SimpleNamespace from typing import Any, ClassVar, cast from flask import Flask @@ -249,7 +250,9 @@ def __getattr__(self, attr: str) -> Any: try: return self._format_kwargs[attr] except KeyError as exc: - raise AttributeError(attr) from exc + raise AttributeError( + attr, name=attr, obj=SimpleNamespace(**self._format_kwargs) + ) from exc def __getitem__(self, key: str) -> Any: """Get a format variable via dictionary access, defaulting to ''.""" diff --git a/funnel/views/account.py b/funnel/views/account.py index a28a7f8bc..3890faf5d 100644 --- a/funnel/views/account.py +++ b/funnel/views/account.py @@ -117,7 +117,7 @@ def organizations_as_admin( owner: bool = False, limit: int | None = None, order_by_grant: bool = False, -) -> list[RoleAccessProxy]: +) -> list[RoleAccessProxy[Account]]: """Return organizations that the user is an admin of.""" if owner: orgmems = obj.active_organization_owner_memberships @@ -139,7 +139,7 @@ def organizations_as_admin( @Account.views() def organizations_as_owner( obj: Account, limit: int | None = None, order_by_grant: bool = False -) -> list[RoleAccessProxy]: +) -> list[RoleAccessProxy[Account]]: """Return organizations that the user is an owner of.""" return obj.views.organizations_as_admin( owner=True, limit=limit, order_by_grant=order_by_grant @@ -173,6 +173,18 @@ def recent_organization_memberships( ) +@Account.views(cached_property=True) +def organizations_as_member(obj: Account) -> list[RoleAccessProxy[Account]]: + """Return organizations that the user has a membership in.""" + return [ + acc.access_for(actor=obj, datasets=('primary', 'related')) + for acc in Account.query.filter( + Account.name_in(app.config['FEATURED_ACCOUNTS']) + ).all() + if 'member' in acc.roles_for(obj) + ] + + @Account.views('avatar_color_code', cached_property=True) def avatar_color_code(obj: Account) -> int: """Return a colour code for the user's autogenerated avatar image.""" @@ -278,20 +290,15 @@ def login_session_service(obj: LoginSession) -> str | None: return None -@route('/account') +@route('/account', init_app=app) class AccountView(ClassView): """Account management views.""" __decorators__ = [requires_login] - obj: Account current_section = 'account' # needed for showing active tab SavedProjectForm = SavedProjectForm - def loader(self, **kwargs) -> Account: - """Return current user.""" - return current_auth.user - @route('', endpoint='account') @render_with('account.html.jinja2') def account(self) -> ReturnRenderWith: @@ -870,9 +877,6 @@ def delete(self): ) -AccountView.init_app(app) - - # --- Compatibility routes ------------------------------------------------------------- diff --git a/funnel/views/api/account.py b/funnel/views/api/account.py index fa386929c..e09a83593 100644 --- a/funnel/views/api/account.py +++ b/funnel/views/api/account.py @@ -19,7 +19,7 @@ def password_policy_check() -> ReturnView: """Check if a password meets policy criteria (strength, embedded personal info).""" form = PasswordPolicyForm(edit_user=current_auth.user) - form.form_nonce.data = form.form_nonce.default() + form.form_nonce.data = form.form_nonce.get_default() if form.validate_on_submit(): return { diff --git a/funnel/views/api/geoname.py b/funnel/views/api/geoname.py index 00c90c463..dc993a497 100644 --- a/funnel/views/api/geoname.py +++ b/funnel/views/api/geoname.py @@ -2,19 +2,21 @@ from __future__ import annotations +from typing import cast + from coaster.utils import getbool from coaster.views import requestargs from ... import app from ...models import GeoName -from ...typing import ReturnRenderWith +from ...typing import ReturnView @app.route('/api/1/geo/get_by_name') @requestargs('name', ('related', getbool), ('alternate_titles', getbool)) def geo_get_by_name( name: str, related: bool = False, alternate_titles: bool = False -) -> ReturnRenderWith: +) -> ReturnView: """Get a geoname record given a single URL stub name or geoname id.""" if name.isdigit(): geoname = GeoName.query.get(int(name)) @@ -36,7 +38,7 @@ def geo_get_by_name( @requestargs('name[]', ('related', getbool), ('alternate_titles', getbool)) def geo_get_by_names( name: list[str], related: bool = False, alternate_titles: bool = False -) -> ReturnRenderWith: +) -> ReturnView: """Get geoname records matching given URL stub names or geonameids.""" geonames = [] for n in name: @@ -57,7 +59,7 @@ def geo_get_by_names( @app.route('/api/1/geo/get_by_title') @requestargs('title[]', 'lang') -def geo_get_by_title(title: list[str], lang: str | None = None) -> ReturnRenderWith: +def geo_get_by_title(title: list[str], lang: str | None = None) -> ReturnView: """Get locations matching given titles.""" return { 'status': 'ok', @@ -73,9 +75,9 @@ def geo_parse_location( lang: str | None = None, bias: list[str] | None = None, alternate_titles: bool = False, -) -> ReturnRenderWith: +) -> ReturnView: """Parse locations from a string of locations.""" - result = GeoName.parse_locations(q, special, lang, bias) + result = cast(list[dict], GeoName.parse_locations(q, special, lang, bias)) for item in result: if 'geoname' in item: item['geoname'] = item['geoname'].as_dict(alternate_titles=alternate_titles) @@ -84,9 +86,7 @@ def geo_parse_location( @app.route('/api/1/geo/autocomplete') @requestargs('q', 'lang', ('limit', int)) -def geo_autocomplete( - q: str, lang: str | None = None, limit: int = 100 -) -> ReturnRenderWith: +def geo_autocomplete(q: str, lang: str | None = None, limit: int = 100) -> ReturnView: """Autocomplete a geoname record.""" return { 'status': 'ok', diff --git a/funnel/views/auth_client.py b/funnel/views/auth_client.py index 558d526c2..6eb7e2e36 100644 --- a/funnel/views/auth_client.py +++ b/funnel/views/auth_client.py @@ -64,7 +64,7 @@ def available_client_owners() -> list[tuple[str, str]]: return choices -@route('/apps/new', methods=['GET', 'POST']) +@route('/apps/new', methods=['GET', 'POST'], init_app=app) class AuthClientCreateView(ClassView): @route('', endpoint='authclient_new') @requires_login @@ -93,15 +93,10 @@ def new(self) -> ReturnView: ) -AuthClientCreateView.init_app(app) - - @AuthClient.views('main') -@route('/apps/info/') -class AuthClientView(UrlForView, ModelView): - model = AuthClient +@route('/apps/info/', init_app=app) +class AuthClientView(UrlForView, ModelView[AuthClient]): route_model_map = {'client': 'buid'} - obj: AuthClient def loader(self, client: str) -> AuthClient: return AuthClient.query.filter(AuthClient.buid == client).one_or_404() @@ -255,17 +250,13 @@ def permission_user_new(self) -> ReturnView: ) -AuthClientView.init_app(app) - # --- Routes: client credentials ---------------------------------------------- @AuthClientCredential.views('main') -@route('/apps/info//cred/') -class AuthClientCredentialView(UrlForView, ModelView): - model = AuthClientCredential +@route('/apps/info//cred/', init_app=app) +class AuthClientCredentialView(UrlForView, ModelView[AuthClientCredential]): route_model_map = {'client': 'auth_client.buid', 'name': 'name'} - obj: AuthClientCredential def loader(self, client: str, name: str) -> AuthClientCredential: return ( @@ -290,18 +281,13 @@ def delete(self) -> ReturnView: ) -AuthClientCredentialView.init_app(app) - - # --- Routes: client app permissions ------------------------------------------ @AuthClientPermissions.views('main') -@route('/apps/info//perms/u/') -class AuthClientPermissionsView(UrlForView, ModelView): - model = AuthClientPermissions +@route('/apps/info//perms/u/', init_app=app) +class AuthClientPermissionsView(UrlForView, ModelView[AuthClientPermissions]): route_model_map = {'client': 'auth_client.buid', 'account': 'account.buid'} - obj: AuthClientPermissions def loader(self, client: str, account: str) -> AuthClientPermissions: return ( @@ -363,22 +349,17 @@ def delete(self) -> ReturnView: ).format( pname=self.obj.account.pickername, title=self.obj.auth_client.title ), - success=_("You have revoked permisions for user {pname}").format( + success=_("You have revoked permissions for user {pname}").format( pname=self.obj.account.pickername ), next=self.obj.auth_client.url_for(), ) -AuthClientPermissionsView.init_app(app) - - @AuthClientTeamPermissions.views('main') -@route('/apps/info//perms/t/') -class AuthClientTeamPermissionsView(UrlForView, ModelView): - model = AuthClientTeamPermissions +@route('/apps/info//perms/t/', init_app=app) +class AuthClientTeamPermissionsView(UrlForView, ModelView[AuthClientTeamPermissions]): route_model_map = {'client': 'auth_client.buid', 'team': 'team.buid'} - obj: AuthClientTeamPermissions def loader(self, client: str, team: str) -> AuthClientTeamPermissions: return ( @@ -438,11 +419,8 @@ def delete(self) -> ReturnView: message=_( "Remove all permissions assigned to team ‘{pname}’ for app ‘{title}’?" ).format(pname=self.obj.team.title, title=self.obj.auth_client.title), - success=_("You have revoked permisions for team {title}").format( + success=_("You have revoked permissions for team {title}").format( title=self.obj.team.title ), next=self.obj.auth_client.url_for(), ) - - -AuthClientTeamPermissionsView.init_app(app) diff --git a/funnel/views/comment.py b/funnel/views/comment.py index ccbebf2cc..c693a52ea 100644 --- a/funnel/views/comment.py +++ b/funnel/views/comment.py @@ -99,7 +99,7 @@ def last_comment(obj: Commentset) -> Comment | None: return None -@route('/comments') +@route('/comments', init_app=app) class AllCommentsView(ClassView): """View for index of commentsets.""" @@ -143,9 +143,6 @@ def view(self, page: int = 1, per_page: int = 20) -> ReturnRenderWith: return result -AllCommentsView.init_app(app) - - def do_post_comment( commentset: Commentset, actor: Account, @@ -164,13 +161,11 @@ def do_post_comment( return comment -@route('/comments/') -class CommentsetView(UrlForView, ModelView): +@route('/comments/', init_app=app) +class CommentsetView(UrlForView, ModelView[Commentset]): """Views for commentset display within a host document.""" - model = Commentset route_model_map = {'commentset': 'uuid_b58'} - obj: Commentset def loader(self, commentset: str) -> Commentset: return Commentset.query.filter(Commentset.uuid_b58 == commentset).one_or_404() @@ -223,7 +218,7 @@ def new(self) -> ReturnView: @requires_login def subscribe(self) -> ReturnView: subscribe_form = CommentsetSubscribeForm() - subscribe_form.form_nonce.data = subscribe_form.form_nonce.default() + subscribe_form.form_nonce.data = subscribe_form.form_nonce.get_default() if subscribe_form.validate_on_submit(): if subscribe_form.subscribe.data: self.obj.add_subscriber( @@ -269,38 +264,26 @@ def update_last_seen_at(self) -> ReturnRenderWith: }, 422 -CommentsetView.init_app(app) - - -@route('/comments//') -class CommentView(UrlForView, ModelView): +@route('/comments//', init_app=app) +class CommentView(UrlForView, ModelView[Comment]): """Views for a single comment.""" - model = Comment route_model_map = {'commentset': 'commentset.uuid_b58', 'comment': 'uuid_b58'} - obj: Comment - def loader(self, commentset: str, comment: str) -> Comment | Commentset: - comment = ( + def load(self, commentset: str, comment: str) -> ReturnView | None: + obj = ( Comment.query.join(Commentset) .filter(Commentset.uuid_b58 == commentset, Comment.uuid_b58 == comment) .one_or_none() ) - if comment is None: - # if the comment doesn't exist or deleted, return the commentset, - # `after_loader()` will redirect to the commentset instead. - return Commentset.query.filter( - Commentset.uuid_b58 == commentset - ).one_or_404() - return comment - - def after_loader(self) -> ReturnView | None: - if isinstance(self.obj, Commentset): - flash( - _("That comment could not be found. It may have been deleted"), 'error' - ) - return render_redirect(self.obj.url_for()) - return super().after_loader() + if obj is not None: + self.obj = obj + return None + commentset_obj = Commentset.query.filter( + Commentset.uuid_b58 == commentset + ).one_or_404() + flash(_("That comment could not be found. It may have been deleted"), 'error') + return render_redirect(commentset_obj.url_for()) @route('') @requires_roles({'reader'}) @@ -446,6 +429,3 @@ def report_spam(self) -> ReturnView: with_chrome=False, ).get_data(as_text=True) return {'status': 'ok', 'form': reportspamform_html} - - -CommentView.init_app(app) diff --git a/funnel/views/contact.py b/funnel/views/contact.py index 43064c7ac..062fec84a 100644 --- a/funnel/views/contact.py +++ b/funnel/views/contact.py @@ -15,7 +15,7 @@ from coaster.views import ClassView, render_with, requestargs, route from .. import app -from ..models import ContactExchange, Project, TicketParticipant, db, sa +from ..models import ContactExchange, Project, TicketParticipant, db, sa_orm from ..typing import ReturnRenderWith, ReturnView from ..utils import format_twitter_handle from .login_session import requires_login @@ -31,14 +31,14 @@ def contact_details(ticket_participant: TicketParticipant) -> dict[str, str | No } -@route('/account/contacts') +@route('/account/contacts', init_app=app) class ContactView(ClassView): current_section = 'account' def get_project(self, uuid_b58): return ( Project.query.filter_by(uuid_b58=uuid_b58) - .options(sa.orm.load_only(Project.id, Project.uuid, Project.title)) + .options(sa_orm.load_only(Project.id, Project.uuid, Project.title)) .one_or_404() ) @@ -182,6 +182,3 @@ def connect(self, puk: str, key: str) -> ReturnView: 'error': '403', 'message': _("Unauthorized contact exchange"), }, 403 - - -ContactView.init_app(app) diff --git a/funnel/views/decorators.py b/funnel/views/decorators.py index b4d98767a..aa2a5afee 100644 --- a/funnel/views/decorators.py +++ b/funnel/views/decorators.py @@ -114,9 +114,8 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnResponse: # 2. Get existing data from cache. There may be multiple copies of data, # for each distinct rhash. Look for the one matching our rhash - # XXX: Typing for cache.get is incorrectly specified as returning # Optional[str] - cache_data: dict | None = cache.get(cache_key) # type: ignore[assignment] + cache_data: dict | None = cache.get(cache_key) response_data = None if cache_data: rhash_data = cache_data.get(rhash, {}) diff --git a/funnel/views/helpers.py b/funnel/views/helpers.py index c87755592..63c43ce40 100644 --- a/funnel/views/helpers.py +++ b/funnel/views/helpers.py @@ -265,7 +265,7 @@ def autoset_timezone_and_locale() -> None: if remapped_timezone is not None: user.timezone = remapped_timezone # type: ignore[assignment] if user.auto_locale or not user.locale or str(user.locale) not in supported_locales: - user.locale = ( + user.locale = ( # pyright: ignore[reportGeneralTypeIssues] request.accept_languages.best_match( # type: ignore[assignment] supported_locales.keys() ) @@ -344,10 +344,7 @@ def validate_rate_limit( tags={'resource': resource}, ) cache_key = f'rate_limit/v1/{resource}/{identifier}' - # XXX: Typing for cache.get is incorrectly specified as returning Optional[str] - cache_value: tuple[int, str] | None = cache.get( # type: ignore[assignment] - cache_key - ) + cache_value: tuple[int, str] | None = cache.get(cache_key) if cache_value is None: count, cache_token = None, None statsd.incr('rate_limit', tags={'resource': resource, 'status_code': 201}) @@ -435,8 +432,7 @@ def make_cached_token(payload: dict, timeout: int = 24 * 60 * 60) -> str: def retrieve_cached_token(token: str) -> dict | None: """Retrieve cached data given a token generated using :func:`make_cached_token`.""" - # XXX: Typing for cache.get is incorrectly specified as returning Optional[str] - return cache.get(TEXT_TOKEN_PREFIX + token) # type: ignore[return-value] + return cache.get(TEXT_TOKEN_PREFIX + token) def delete_cached_token(token: str) -> bool: diff --git a/funnel/views/index.py b/funnel/views/index.py index 31d789c7f..7b176628c 100644 --- a/funnel/views/index.py +++ b/funnel/views/index.py @@ -38,7 +38,7 @@ class PolicyPage: ] -@route('/') +@route('/', init_app=app) class IndexView(ClassView): current_section = 'home' SavedProjectForm = SavedProjectForm @@ -127,7 +127,9 @@ def home(self) -> ReturnRenderWith: featured_account_sort_key = { _n.lower(): _i for _i, _n in enumerate(app.config['FEATURED_ACCOUNTS']) } - featured_accounts.sort(key=lambda a: featured_account_sort_key[a.name.lower()]) + featured_accounts.sort( + key=lambda a: featured_account_sort_key[(a.name or a.title).lower()] + ) return { 'all_projects': [ @@ -151,40 +153,38 @@ def home(self) -> ReturnRenderWith: 'featured_project_sessions': scheduled_sessions_list, 'featured_project_schedule': featured_project_schedule, 'featured_accounts': [ - p.access_for(roles={'all'}, datasets=('primary', 'related')) + p.current_access(datasets=('primary', 'related')) for p in featured_accounts ], } - -IndexView.init_app(app) - - -@app.route('/past.projects', endpoint='past_projects') -@requestargs(('page', int), ('per_page', int)) -@render_with('past_projects_section.html.jinja2') -def past_projects(page: int = 1, per_page: int = 10) -> ReturnView: - g.account = None - projects = Project.all_unsorted() - pagination = ( - projects.filter(Project.state.PAST) - .order_by(Project.start_at.desc()) - .paginate(page=page, per_page=per_page) - ) - return { - 'status': 'ok', - 'next_page': pagination.page + 1 if pagination.page < pagination.pages else '', - 'total_pages': pagination.pages, - 'past_projects': [ - { - 'title': p.title, - 'datetime': date_filter(p.end_at_localized, format='dd MMM yyyy'), - 'venue': p.primary_venue.city if p.primary_venue else p.location, - 'url': p.url_for(), - } - for p in pagination.items - ], - } + @route('past.projects', endpoint='past_projects') + @render_with('past_projects_section.html.jinja2') + @requestargs(('page', int), ('per_page', int)) + def past_projects(self, page: int = 1, per_page: int = 10) -> ReturnRenderWith: + g.account = None + projects = Project.all_unsorted() + pagination = ( + projects.filter(Project.state.PAST) + .order_by(Project.start_at.desc()) + .paginate(page=page, per_page=per_page) + ) + return { + 'status': 'ok', + 'next_page': pagination.page + 1 + if pagination.page < pagination.pages + else '', + 'total_pages': pagination.pages, + 'past_projects': [ + { + 'title': p.title, + 'datetime': date_filter(p.end_at_localized, format='dd MMM yyyy'), + 'venue': p.primary_venue.city if p.primary_venue else p.location, + 'url': p.url_for(), + } + for p in pagination.items + ], + } @app.route('/about') diff --git a/funnel/views/jobs.py b/funnel/views/jobs.py index 6282f1821..4a6f67d0f 100644 --- a/funnel/views/jobs.py +++ b/funnel/views/jobs.py @@ -78,21 +78,17 @@ def import_tickets(ticket_client_id: int) -> None: @rqjob() def tag_locations(project_id: int) -> None: - """ - Tag a project with geoname locations. - - This function used to retrieve data from Hascore, which has been merged into Funnel - and is available directly as the GeoName model. This code continues to operate with - the legacy Hascore data structure, and is pending rewrite. - """ + """Tag a project with geoname locations. This is legacy code pending a rewrite.""" project = Project.query.get(project_id) + if project is None: + return if not project.location: return results = GeoName.parse_locations( project.location, special=["Internet", "Online"], bias=['IN', 'US'] ) - geonames = defaultdict(dict) - tokens = [] + geonames: dict[str, dict] = defaultdict(dict) + tokens: list[dict] = [] for item in results: if 'geoname' in item: geoname = item['geoname'].as_dict(alternate_titles=False) diff --git a/funnel/views/label.py b/funnel/views/label.py index 962c468fe..6aad55b28 100644 --- a/funnel/views/label.py +++ b/funnel/views/label.py @@ -15,12 +15,12 @@ from ..typing import ReturnRenderWith, ReturnView from .helpers import render_redirect from .login_session import requires_login, requires_sudo -from .mixins import AccountCheckMixin, ProjectViewMixin +from .mixins import AccountCheckMixin, ProjectViewBase @Project.views('label') -@route('///labels') -class ProjectLabelView(ProjectViewMixin, UrlForView, ModelView): +@route('///labels', init_app=app) +class ProjectLabelView(ProjectViewBase): @route('', methods=['GET', 'POST']) @render_with('labels.html.jinja2') @requires_login @@ -96,20 +96,15 @@ def new_label(self) -> ReturnRenderWith: } -ProjectLabelView.init_app(app) - - @Label.views('main') -@route('///labels/