{description}
{schema}
'
+ ).format(
+ description=self.register_form_schema.description,
+ schema=json.dumps(FORM_SCHEMA_PLACEHOLDER, indent=2),
+ )
@TicketEvent.forms('main')
@@ -121,7 +165,7 @@ class TicketParticipantForm(forms.Form):
"""Form for a participant in a ticket."""
__returns__ = ('user',)
- user: Optional[User] = None
+ user: Account | None = None
edit_parent: Project
fullname = forms.StringField(
@@ -131,8 +175,8 @@ class TicketParticipantForm(forms.Form):
)
email = forms.EmailField(
__("Email"),
- validators=[forms.validators.DataRequired(), forms.validators.ValidEmail()],
- filters=[forms.filters.strip()],
+ validators=[forms.validators.Optional(), forms.validators.ValidEmail()],
+ filters=[forms.filters.none_if_empty()],
)
phone = forms.StringField(
__("Phone number"),
@@ -175,10 +219,13 @@ def set_queries(self) -> None:
def validate(self, *args, **kwargs) -> bool:
"""Validate form."""
result = super().validate(*args, **kwargs)
+ if self.email.data is None:
+ self.user = None
+ return True
with db.session.no_autoflush:
- useremail = UserEmail.get(email=self.email.data)
- if useremail is not None:
- self.user = useremail.user
+ accountemail = AccountEmail.get(email=self.email.data)
+ if accountemail is not None:
+ self.user = accountemail.account
else:
self.user = None
return result
diff --git a/funnel/forms/update.py b/funnel/forms/update.py
index 3ef0cc4eb..15e121c27 100644
--- a/funnel/forms/update.py
+++ b/funnel/forms/update.py
@@ -27,5 +27,5 @@ class UpdateForm(forms.Form):
__("Pin this update above other updates"), default=False
)
is_restricted = forms.BooleanField(
- __("Limit visibility to participants only"), default=False
+ __("Limit access to current participants only"), default=False
)
diff --git a/funnel/forms/venue.py b/funnel/forms/venue.py
index 37f7d94bf..b43cc44ff 100644
--- a/funnel/forms/venue.py
+++ b/funnel/forms/venue.py
@@ -5,9 +5,8 @@
import gettext
import re
-from flask_babel import get_locale
-
import pycountry
+from flask_babel import get_locale
from baseframe import _, __, forms
from baseframe.forms.sqlalchemy import QuerySelectField
@@ -102,7 +101,7 @@ class VenueRoomForm(forms.Form):
default="CCCCCC",
)
- def validate_bgcolor(self, field) -> None:
+ def validate_bgcolor(self, field: forms.Field) -> None:
"""Validate colour to be in RGB."""
if not valid_color_re.match(field.data):
raise forms.validators.ValidationError(
diff --git a/funnel/geoip.py b/funnel/geoip.py
new file mode 100644
index 000000000..0c15a5854
--- /dev/null
+++ b/funnel/geoip.py
@@ -0,0 +1,53 @@
+"""GeoIP databases."""
+
+import os.path
+from dataclasses import dataclass
+
+from flask import Flask
+from geoip2.database import Reader
+from geoip2.errors import AddressNotFoundError, GeoIP2Error
+from geoip2.models import ASN, City
+
+__all__ = ['GeoIP', 'geoip', 'GeoIP2Error', 'AddressNotFoundError']
+
+
+@dataclass
+class GeoIP:
+ """Wrapper for GeoIP2 Reader."""
+
+ city_db: Reader | None = None
+ asn_db: Reader | None = None
+
+ def __bool__(self) -> bool:
+ return self.city_db is not None or self.asn_db is not None
+
+ def city(self, ipaddr: str) -> City | None:
+ if self.city_db:
+ return self.city_db.city(ipaddr)
+ return None
+
+ def asn(self, ipaddr: str) -> ASN | None:
+ if self.asn_db:
+ return self.asn_db.asn(ipaddr)
+ return None
+
+ def init_app(self, app: Flask) -> None:
+ if 'GEOIP_DB_CITY' in app.config:
+ if not os.path.exists(app.config['GEOIP_DB_CITY']):
+ app.logger.warning(
+ "GeoIP city database missing at %s", app.config['GEOIP_DB_CITY']
+ )
+ else:
+ self.city_db = Reader(app.config['GEOIP_DB_CITY'])
+
+ if 'GEOIP_DB_ASN' in app.config:
+ if not os.path.exists(app.config['GEOIP_DB_ASN']):
+ app.logger.warning(
+ "GeoIP ASN database missing at %s", app.config['GEOIP_DB_ASN']
+ )
+ else:
+ self.asn_db = Reader(app.config['GEOIP_DB_ASN'])
+
+
+# Export a singleton
+geoip = GeoIP()
diff --git a/funnel/loginproviders/github.py b/funnel/loginproviders/github.py
index 59ffc5770..b16257523 100644
--- a/funnel/loginproviders/github.py
+++ b/funnel/loginproviders/github.py
@@ -2,11 +2,10 @@
from __future__ import annotations
+import requests
from flask import current_app, redirect, request
-
from furl import furl
from sentry_sdk import capture_exception
-import requests
from baseframe import _
diff --git a/funnel/loginproviders/google.py b/funnel/loginproviders/google.py
index c2af4c9b8..48d030c01 100644
--- a/funnel/loginproviders/google.py
+++ b/funnel/loginproviders/google.py
@@ -2,11 +2,10 @@
from __future__ import annotations
+import requests
from flask import current_app, redirect, request, session
-
from oauth2client import client
from sentry_sdk import capture_exception
-import requests
from baseframe import _
diff --git a/funnel/loginproviders/linkedin.py b/funnel/loginproviders/linkedin.py
index a5a601ed5..9920ca266 100644
--- a/funnel/loginproviders/linkedin.py
+++ b/funnel/loginproviders/linkedin.py
@@ -4,11 +4,10 @@
from secrets import token_urlsafe
+import requests
from flask import current_app, redirect, request, session
-
from furl import furl
from sentry_sdk import capture_exception
-import requests
from baseframe import _
@@ -18,8 +17,8 @@
class LinkedInProvider(LoginProvider):
- auth_url = 'https://www.linkedin.com/uas/oauth2/authorization?response_type=code'
- token_url = 'https://www.linkedin.com/uas/oauth2/accessToken' # nosec
+ auth_url = 'https://www.linkedin.com/oauth/v2/authorization?response_type=code'
+ token_url = 'https://www.linkedin.com/oauth/v2/accessToken' # nosec
user_info = (
'https://api.linkedin.com/v2/me?'
'projection=(id,localizedFirstName,localizedLastName)'
diff --git a/funnel/loginproviders/twitter.py b/funnel/loginproviders/twitter.py
index 74849ae81..243d99714 100644
--- a/funnel/loginproviders/twitter.py
+++ b/funnel/loginproviders/twitter.py
@@ -2,9 +2,8 @@
from __future__ import annotations
-from flask import redirect, request
-
import tweepy
+from flask import redirect, request
from baseframe import _
diff --git a/funnel/loginproviders/zoom.py b/funnel/loginproviders/zoom.py
index 3aaec6424..9478548e9 100644
--- a/funnel/loginproviders/zoom.py
+++ b/funnel/loginproviders/zoom.py
@@ -4,11 +4,10 @@
from base64 import b64encode
+import requests
from flask import current_app, redirect, request, session
-
from furl import furl
from sentry_sdk import capture_exception
-import requests
from baseframe import _
diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py
index 84f2b24e4..eb243c69d 100644
--- a/funnel/models/__init__.py
+++ b/funnel/models/__init__.py
@@ -1,63 +1,78 @@
"""Provide configuration for models and import all into a common `models` namespace."""
# flake8: noqa
+# pylint: disable=unused-import
from __future__ import annotations
-from typing import TYPE_CHECKING, Callable, TypeVar
-
+import sqlalchemy as sa
from flask_sqlalchemy import SQLAlchemy
-from flask_sqlalchemy.model import DefaultMeta
-from flask_sqlalchemy.model import Model as ModelBase
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.hybrid import hybrid_property
-from sqlalchemy.orm import Mapped, declarative_mixin, declared_attr
-from sqlalchemy_json import mutable_json_type
+from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, declared_attr
from sqlalchemy_utils import LocaleType, TimezoneType, TSVectorType
-import sqlalchemy as sa # noqa
-import sqlalchemy.orm # Required to make sa.orm work # noqa
from coaster.sqlalchemy import (
BaseIdNameMixin,
BaseMixin,
BaseNameMixin,
+ BaseScopedIdMixin,
BaseScopedIdNameMixin,
BaseScopedNameMixin,
CoordinatesMixin,
+ DynamicMapped,
+ ModelBase,
NoIdMixin,
+ Query,
+ QueryProperty,
RegistryMixin,
RoleMixin,
TimestampMixin,
UrlType,
UuidMixin,
+ backref,
+ relationship,
with_roles,
)
-json_type: postgresql.JSONB = mutable_json_type(dbtype=postgresql.JSONB, nested=True)
-db = SQLAlchemy()
+class Model(ModelBase, DeclarativeBase):
+ """Base for all models."""
+
+ __with_timezone__ = True
+
+
+class GeonameModel(ModelBase, DeclarativeBase):
+ """Base for geoname models."""
+
+ __bind_key__ = 'geoname'
+ __with_timezone__ = True
+
-# This must be set _before_ any of the models are imported
+# This must be set _before_ any of the models using db.Model are imported
TimestampMixin.__with_timezone__ = True
+db = SQLAlchemy(query_class=Query, metadata=Model.metadata) # type: ignore[arg-type]
+Model.init_flask_sqlalchemy(db)
+GeonameModel.init_flask_sqlalchemy(db)
+
# Some of these imports are order sensitive due to circular dependencies
# All of them have to be imported after TimestampMixin is patched
# pylint: disable=wrong-import-position
+from . import types # isort:skip
from .helpers import * # isort:skip
-from .user import * # isort:skip
+from .account import * # isort:skip
from .user_signals import * # isort:skip
-from .user_session import * # isort:skip
+from .login_session import * # isort:skip
from .email_address import * # isort:skip
from .phone_number import * # isort:skip
from .auth_client import * # isort:skip
-from .notification import * # isort:skip
from .utils import * # isort:skip
from .comment import * # isort:skip
from .draft import * # isort:skip
from .sync_ticket import * # isort:skip
from .contact_exchange import * # isort:skip
from .label import * # isort:skip
-from .profile import * # isort:skip
from .project import * # isort:skip
from .update import * # isort:skip
from .proposal import * # isort:skip
@@ -67,13 +82,16 @@
from .shortlink import * # isort:skip
from .venue import * # isort:skip
from .video_mixin import * # isort:skip
+from .mailer import * # isort:skip
from .membership_mixin import * # isort:skip
-from .organization_membership import * # isort:skip
+from .account_membership import * # isort:skip
from .project_membership import * # isort:skip
from .sponsor_membership import * # isort:skip
from .proposal_membership import * # isort:skip
from .site_membership import * # isort:skip
from .moderation import * # isort:skip
-from .notification_types import * # isort:skip
from .commentset_membership import * # isort:skip
from .geoname import * # isort:skip
+from .typing import * # isort:skip
+from .notification import * # isort:skip
+from .notification_types import * # isort:skip
diff --git a/funnel/models/account.py b/funnel/models/account.py
new file mode 100644
index 000000000..bbc34da48
--- /dev/null
+++ b/funnel/models/account.py
@@ -0,0 +1,2186 @@
+"""Account model with subtypes, and account-linked personal data models."""
+
+from __future__ import annotations
+
+import hashlib
+import itertools
+from collections.abc import Iterable, Iterator
+from datetime import datetime, timedelta
+from typing import ClassVar, Literal, Union, cast, overload
+from uuid import UUID
+
+import phonenumbers
+from babel import Locale
+from furl import furl
+from passlib.hash import argon2, bcrypt
+from pytz.tzinfo import BaseTzInfo
+from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
+from sqlalchemy.ext.hybrid import Comparator
+from sqlalchemy.sql.expression import ColumnElement
+from werkzeug.utils import cached_property
+from zbase32 import decode as zbase32_decode, encode as zbase32_encode
+
+from baseframe import __
+from coaster.sqlalchemy import (
+ LazyRoleSet,
+ RoleMixin,
+ StateManager,
+ add_primary_relationship,
+ auto_init_default,
+ failsafe_add,
+ immutable,
+ with_roles,
+)
+from coaster.utils import LabeledEnum, newsecret, require_one_of, utcnow
+
+from ..typing import OptionalMigratedTables
+from . import (
+ BaseMixin,
+ DynamicMapped,
+ LocaleType,
+ Mapped,
+ Model,
+ Query,
+ TimezoneType,
+ TSVectorType,
+ UrlType,
+ UuidMixin,
+ backref,
+ db,
+ hybrid_property,
+ relationship,
+ sa,
+)
+from .email_address import EmailAddress, EmailAddressMixin
+from .helpers import (
+ RESERVED_NAMES,
+ ImgeeType,
+ MarkdownCompositeDocument,
+ add_search_trigger,
+ quote_autocomplete_like,
+ quote_autocomplete_tsquery,
+ valid_account_name,
+ visual_field_delimiter,
+)
+from .phone_number import PhoneNumber, PhoneNumberMixin
+
+__all__ = [
+ 'ACCOUNT_STATE',
+ 'deleted_account',
+ 'removed_account',
+ 'unknown_account',
+ 'User',
+ 'DuckTypeAccount',
+ 'AccountOldId',
+ 'Organization',
+ 'Team',
+ 'Placeholder',
+ 'AccountEmail',
+ 'AccountEmailClaim',
+ 'AccountPhone',
+ 'AccountExternalId',
+ 'Anchor',
+]
+
+
+class ACCOUNT_STATE(LabeledEnum): # noqa: N801
+ """State codes for accounts."""
+
+ #: Regular, active account
+ ACTIVE = (1, __("Active"))
+ #: Suspended account (cause and explanation not included here)
+ SUSPENDED = (2, __("Suspended"))
+ #: Merged into another account
+ MERGED = (3, __("Merged"))
+ #: Permanently deleted account
+ DELETED = (5, __("Deleted"))
+
+ #: This account is gone
+ GONE = {MERGED, DELETED}
+
+
+class PROFILE_STATE(LabeledEnum): # noqa: N801
+ """The visibility state of an account (auto/public/private)."""
+
+ AUTO = (1, 'auto', __("Autogenerated"))
+ PUBLIC = (2, 'public', __("Public"))
+ PRIVATE = (3, 'private', __("Private"))
+
+ NOT_PUBLIC = {AUTO, PRIVATE}
+ NOT_PRIVATE = {AUTO, PUBLIC}
+
+
+class ZBase32Comparator(Comparator[str]): # pylint: disable=abstract-method
+ """Comparator to allow lookup by Account.uuid_zbase32."""
+
+ def __eq__(self, other: object) -> sa.ColumnElement[bool]: # type: ignore[override]
+ """Return an expression for column == other."""
+ try:
+ return self.__clause_element__() == UUID( # type: ignore[return-value]
+ bytes=zbase32_decode(str(other))
+ )
+ except ValueError: # zbase32 call failed, so it's not a valid string
+ return sa.false()
+
+
+class Account(UuidMixin, BaseMixin, Model):
+ """Account model."""
+
+ __tablename__ = 'account'
+ # Name has a length limit 63 to fit DNS label limit
+ __name_length__ = 63
+ # Titles can be longer
+ __title_length__ = 80
+
+ __active_membership_attrs__: ClassVar[set[str]] = set()
+ __noninvite_membership_attrs__: ClassVar[set[str]] = set()
+
+ # Helper flags (see subclasses)
+ is_user_profile: ClassVar[bool] = False
+ is_organization_profile: ClassVar[bool] = False
+ is_placeholder_profile: ClassVar[bool] = False
+
+ reserved_names: ClassVar[set[str]] = RESERVED_NAMES
+
+ type_: Mapped[str] = sa.orm.mapped_column('type', sa.CHAR(1), nullable=False)
+
+ #: Join date for users and organizations (skipped for placeholders)
+ joined_at: Mapped[datetime | None] = sa.orm.mapped_column(
+ sa.TIMESTAMP(timezone=True), nullable=True
+ )
+
+ #: The optional "username", used in the URL stub, with a unique constraint on the
+ #: lowercase value (defined in __table_args__ below)
+ name: Mapped[str | None] = with_roles(
+ sa.orm.mapped_column(
+ sa.Unicode(__name_length__),
+ sa.CheckConstraint("name <> ''"),
+ nullable=True,
+ ),
+ read={'all'},
+ )
+
+ #: The account's title (user's fullname)
+ title: Mapped[str] = with_roles(
+ sa.orm.mapped_column(sa.Unicode(__title_length__), default='', nullable=False),
+ read={'all'},
+ )
+ #: Alias title as user's fullname
+ fullname: Mapped[str] = sa.orm.synonym('title')
+ #: Alias name as user's username
+ username: Mapped[str] = sa.orm.synonym('name')
+
+ #: Argon2 or Bcrypt hash of the user's password
+ pw_hash: Mapped[str | None] = sa.orm.mapped_column(sa.Unicode, nullable=True)
+ #: Timestamp for when the user's password last changed
+ pw_set_at: Mapped[datetime | None] = sa.orm.mapped_column(
+ sa.TIMESTAMP(timezone=True), nullable=True
+ )
+ #: Expiry date for the password (to prompt user to reset it)
+ pw_expires_at: Mapped[datetime | None] = sa.orm.mapped_column(
+ sa.TIMESTAMP(timezone=True), nullable=True
+ )
+ #: User's preferred/last known timezone
+ timezone: Mapped[BaseTzInfo | None] = with_roles(
+ sa.orm.mapped_column(TimezoneType(backend='pytz'), nullable=True),
+ read={'owner'},
+ )
+ #: Update timezone automatically from browser activity
+ auto_timezone: Mapped[bool] = sa.orm.mapped_column(
+ sa.Boolean, default=True, nullable=False
+ )
+ #: User's preferred/last known locale
+ locale: Mapped[Locale | None] = with_roles(
+ sa.orm.mapped_column(LocaleType, nullable=True), read={'owner'}
+ )
+ #: Update locale automatically from browser activity
+ auto_locale: Mapped[bool] = sa.orm.mapped_column(
+ sa.Boolean, default=True, nullable=False
+ )
+ #: User's state code (active, suspended, merged, deleted)
+ _state: Mapped[int] = sa.orm.mapped_column(
+ 'state',
+ sa.SmallInteger,
+ StateManager.check_constraint('state', ACCOUNT_STATE),
+ nullable=False,
+ default=ACCOUNT_STATE.ACTIVE,
+ )
+ #: Account state manager
+ state = StateManager('_state', ACCOUNT_STATE, doc="Account state")
+ #: Other accounts that were merged into this account
+ old_accounts: AssociationProxy[list[Account]] = association_proxy(
+ 'oldids', 'old_account'
+ )
+
+ _profile_state: Mapped[int] = sa.orm.mapped_column(
+ 'profile_state',
+ sa.SmallInteger,
+ StateManager.check_constraint('profile_state', PROFILE_STATE),
+ nullable=False,
+ default=PROFILE_STATE.AUTO,
+ )
+ profile_state = StateManager(
+ '_profile_state', PROFILE_STATE, doc="Current state of the account profile"
+ )
+
+ tagline: Mapped[str | None] = sa.orm.mapped_column(
+ sa.Unicode, sa.CheckConstraint("tagline <> ''"), nullable=True
+ )
+ description, description_text, description_html = MarkdownCompositeDocument.create(
+ 'description', default='', nullable=False
+ )
+ website: Mapped[furl | None] = sa.orm.mapped_column(
+ UrlType, sa.CheckConstraint("website <> ''"), nullable=True
+ )
+ logo_url: Mapped[furl | None] = sa.orm.mapped_column(
+ ImgeeType, sa.CheckConstraint("logo_url <> ''"), nullable=True
+ )
+ banner_image_url: Mapped[furl | None] = sa.orm.mapped_column(
+ ImgeeType, sa.CheckConstraint("banner_image_url <> ''"), nullable=True
+ )
+
+ # These two flags are read-only. There is no provision for writing to them within
+ # the app:
+
+ #: Protected accounts cannot be deleted
+ is_protected: Mapped[bool] = with_roles(
+ immutable(sa.orm.mapped_column(sa.Boolean, default=False, nullable=False)),
+ read={'owner', 'admin'},
+ )
+ #: Verified accounts get listed on the home page and are not considered throwaway
+ #: accounts for spam control. There are no other privileges at this time
+ is_verified: Mapped[bool] = with_roles(
+ sa.orm.mapped_column(sa.Boolean, default=False, nullable=False, index=True),
+ read={'all'},
+ )
+
+ #: Revision number maintained by SQLAlchemy, starting at 1
+ revisionid: Mapped[int] = with_roles(
+ sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'}
+ )
+
+ search_vector: Mapped[str] = sa.orm.mapped_column(
+ TSVectorType(
+ 'title',
+ 'name',
+ 'tagline',
+ 'description_text',
+ weights={
+ 'title': 'A',
+ 'name': 'A',
+ 'tagline': 'B',
+ 'description_text': 'B',
+ },
+ regconfig='english',
+ hltext=lambda: sa.func.concat_ws(
+ visual_field_delimiter,
+ Account.title,
+ Account.name,
+ Account.tagline,
+ Account.description_html,
+ ),
+ ),
+ nullable=False,
+ deferred=True,
+ )
+
+ name_vector: Mapped[str] = sa.orm.mapped_column(
+ TSVectorType(
+ 'title',
+ 'name',
+ regconfig='simple',
+ hltext=lambda: sa.func.concat_ws(' @', Account.title, Account.name),
+ ),
+ nullable=False,
+ deferred=True,
+ )
+
+ __table_args__ = (
+ sa.Index(
+ 'ix_account_name_lower',
+ sa.func.lower(name).label('name_lower'),
+ unique=True,
+ postgresql_ops={'name_lower': 'varchar_pattern_ops'},
+ ),
+ sa.Index(
+ 'ix_account_title_lower',
+ sa.func.lower(title).label('title_lower'),
+ postgresql_ops={'title_lower': 'varchar_pattern_ops'},
+ ),
+ sa.Index('ix_account_search_vector', 'search_vector', postgresql_using='gin'),
+ sa.Index('ix_account_name_vector', 'name_vector', postgresql_using='gin'),
+ )
+
+ __mapper_args__ = {
+ # 'polymorphic_identity' from subclasses is stored in the type column
+ 'polymorphic_on': type_,
+ # When querying the Account model, cast automatically to all subclasses
+ 'with_polymorphic': '*',
+ 'version_id_col': revisionid,
+ }
+
+ __roles__ = {
+ 'all': {
+ 'read': {
+ 'uuid',
+ 'name',
+ 'urlname',
+ 'title',
+ 'fullname',
+ 'username',
+ 'pickername',
+ 'timezone',
+ 'description',
+ 'website',
+ 'logo_url',
+ 'banner_image_url',
+ 'joined_at',
+ 'absolute_url',
+ 'urls',
+ 'is_user_profile',
+ 'is_organization_profile',
+ 'is_placeholder_profile',
+ },
+ 'call': {'views', 'forms', 'features', 'url_for', 'state', 'profile_state'},
+ }
+ }
+
+ __datasets__ = {
+ 'primary': {
+ 'urls',
+ 'uuid_b58',
+ 'name',
+ 'urlname',
+ 'title',
+ 'fullname',
+ 'username',
+ 'pickername',
+ 'timezone',
+ 'description',
+ 'logo_url',
+ 'website',
+ 'joined_at',
+ 'absolute_url',
+ 'is_verified',
+ },
+ 'related': {
+ 'urls',
+ 'uuid_b58',
+ 'name',
+ 'urlname',
+ 'title',
+ 'fullname',
+ 'username',
+ 'pickername',
+ 'timezone',
+ 'description',
+ 'logo_url',
+ 'joined_at',
+ 'absolute_url',
+ 'is_verified',
+ },
+ }
+
+ profile_state.add_conditional_state(
+ 'ACTIVE_AND_PUBLIC',
+ profile_state.PUBLIC,
+ lambda account: bool(account.state.ACTIVE),
+ )
+
+ @classmethod
+ def _defercols(cls) -> list[sa.orm.interfaces.LoaderOption]:
+ """Return columns that are typically deferred when loading a user."""
+ defer = sa.orm.defer
+ return [
+ defer(cls.created_at),
+ defer(cls.updated_at),
+ defer(cls.pw_hash),
+ defer(cls.pw_set_at),
+ defer(cls.pw_expires_at),
+ defer(cls.timezone),
+ ]
+
+ @classmethod
+ def type_filter(cls) -> sa.ColumnElement[bool]:
+ """Return filter for the subclass's type."""
+ return cls.type_ == cls.__mapper_args__.get('polymorphic_identity')
+
+ primary_email: Mapped[AccountEmail | None] = relationship()
+ primary_phone: Mapped[AccountPhone | None] = relationship()
+
+ def __repr__(self) -> str:
+ if self.name:
+ return f'<{self.__class__.__name__} {self.title} @{self.name}>'
+ return f'<{self.__class__.__name__} {self.title}>'
+
+ def __str__(self) -> str:
+ """Return picker name for account."""
+ return self.pickername
+
+ def __format__(self, format_spec: str) -> str:
+ if not format_spec:
+ return self.pickername
+ return self.pickername.__format__(format_spec)
+
+ @property
+ def pickername(self) -> str:
+ """Return title and @name in a format suitable for identification."""
+ if self.name:
+ return f'{self.title} (@{self.name})'
+ return self.title
+
+ with_roles(pickername, read={'all'})
+
+ def roles_for(
+ self, actor: Account | None = None, anchors: Iterable = ()
+ ) -> LazyRoleSet:
+ """Identify roles for the given actor."""
+ roles = super().roles_for(actor, anchors)
+ if self.profile_state.ACTIVE_AND_PUBLIC:
+ roles.add('reader')
+ return roles
+
+ @cached_property
+ def verified_contact_count(self) -> int:
+ """Count of verified contact details."""
+ return len(self.emails) + len(self.phones)
+
+ @property
+ def has_verified_contact_info(self) -> bool:
+ """User has any verified contact info (email or phone)."""
+ return bool(self.emails) or bool(self.phones)
+
+ @property
+ def has_contact_info(self) -> bool:
+ """User has any contact information (including unverified)."""
+ return self.has_verified_contact_info or bool(self.emailclaims)
+
+ def merged_account(self) -> Account:
+ """Return the account that this account was merged into (default: self)."""
+ if self.state.MERGED:
+ # If our state is MERGED, there _must_ be a corresponding AccountOldId
+ # record
+ return cast(AccountOldId, AccountOldId.get(self.uuid)).account
+ return self
+
+ def _set_password(self, password: str | None):
+ """Set a password (write-only property)."""
+ if password is None:
+ self.pw_hash = None
+ else:
+ self.pw_hash = argon2.hash(password)
+ # Also see :meth:`password_is` for transparent upgrade
+ self.pw_set_at = sa.func.utcnow()
+ # Expire passwords after one year. TODO: make this configurable
+ self.pw_expires_at = self.pw_set_at + timedelta(days=365)
+
+ #: Write-only property (passwords cannot be read back in plain text)
+ password = property(fset=_set_password, doc=_set_password.__doc__)
+
+ def password_has_expired(self) -> bool:
+ """Verify if password expiry timestamp has passed."""
+ return (
+ self.pw_hash is not None
+ and self.pw_expires_at is not None
+ and self.pw_expires_at <= utcnow()
+ )
+
+ def password_is(self, password: str, upgrade_hash: bool = False) -> bool:
+ """Test if the candidate password matches saved hash."""
+ if self.pw_hash is None:
+ return False
+
+ # Passwords may use the current Argon2 scheme or the older Bcrypt scheme.
+ # Bcrypt passwords are transparently upgraded if requested.
+ if argon2.identify(self.pw_hash):
+ return argon2.verify(password, self.pw_hash)
+ if bcrypt.identify(self.pw_hash):
+ verified = bcrypt.verify(password, self.pw_hash)
+ if verified and upgrade_hash:
+ self.pw_hash = argon2.hash(password)
+ return verified
+ return False
+
+ def add_email(
+ self,
+ email: str,
+ primary: bool = False,
+ private: bool = False,
+ ) -> AccountEmail:
+ """Add an email address (assumed to be verified)."""
+ accountemail = AccountEmail(account=self, email=email, private=private)
+ accountemail = cast(
+ AccountEmail,
+ failsafe_add(
+ db.session,
+ accountemail,
+ account=self,
+ email_address=accountemail.email_address,
+ ),
+ )
+ if primary:
+ self.primary_email = accountemail
+ return accountemail
+ # FIXME: This should remove competing instances of AccountEmailClaim
+
+ def del_email(self, email: str) -> None:
+ """Remove an email address from the user's account."""
+ accountemail = AccountEmail.get_for(account=self, email=email)
+ if accountemail is not None:
+ if self.primary_email in (accountemail, None):
+ self.primary_email = (
+ AccountEmail.query.filter(
+ AccountEmail.account == self, AccountEmail.id != accountemail.id
+ )
+ .order_by(AccountEmail.created_at.desc())
+ .first()
+ )
+ db.session.delete(accountemail)
+
+ @property
+ def email(self) -> Literal[''] | AccountEmail:
+ """Return primary email address for user."""
+ # Look for a primary address
+ accountemail = self.primary_email
+ if accountemail is not None:
+ return accountemail
+ # No primary? Maybe there's one that's not set as primary?
+ if self.emails:
+ accountemail = self.emails[0]
+ # XXX: Mark as primary. This may or may not be saved depending on
+ # whether the request ended in a database commit.
+ self.primary_email = accountemail
+ return accountemail
+ # This user has no email address. Return a blank string instead of None
+ # to support the common use case, where the caller will use str(user.email)
+ # to get the email address as a string.
+ return ''
+
+ with_roles(email, read={'owner'})
+
+ def add_phone(
+ self,
+ phone: str,
+ primary: bool = False,
+ private: bool = False,
+ ) -> AccountPhone:
+ """Add a phone number (assumed to be verified)."""
+ accountphone = AccountPhone(account=self, phone=phone, private=private)
+ accountphone = cast(
+ AccountPhone,
+ failsafe_add(
+ db.session,
+ accountphone,
+ account=self,
+ phone_number=accountphone.phone_number,
+ ),
+ )
+ if primary:
+ self.primary_phone = accountphone
+ return accountphone
+
+ def del_phone(self, phone: str) -> None:
+ """Remove a phone number from the user's account."""
+ accountphone = AccountPhone.get_for(account=self, phone=phone)
+ if accountphone is not None:
+ if self.primary_phone in (accountphone, None):
+ self.primary_phone = (
+ AccountPhone.query.filter(
+ AccountPhone.account == self, AccountPhone.id != accountphone.id
+ )
+ .order_by(AccountPhone.created_at.desc())
+ .first()
+ )
+ db.session.delete(accountphone)
+
+ @property
+ def phone(self) -> Literal[''] | AccountPhone:
+ """Return primary phone number for user."""
+ # Look for a primary phone number
+ accountphone = self.primary_phone
+ if accountphone is not None:
+ return accountphone
+ # No primary? Maybe there's one that's not set as primary?
+ if self.phones:
+ accountphone = self.phones[0]
+ # XXX: Mark as primary. This may or may not be saved depending on
+ # whether the request ended in a database commit.
+ self.primary_phone = accountphone
+ return accountphone
+ # This user has no phone number. Return a blank string instead of None
+ # to support the common use case, where the caller will use str(user.phone)
+ # to get the phone number as a string.
+ return ''
+
+ with_roles(phone, read={'owner'})
+
+ @property
+ def has_public_profile(self) -> bool:
+ """Return the visibility state of an account."""
+ return self.name is not None and bool(self.profile_state.ACTIVE_AND_PUBLIC)
+
+ with_roles(has_public_profile, read={'all'}, write={'owner'})
+
+ def is_profile_complete(self) -> bool:
+ """Verify if profile is complete (fullname, username and contacts present)."""
+ return bool(self.title and self.name and self.has_verified_contact_info)
+
+ def active_memberships(self) -> Iterator[ImmutableMembershipMixin]:
+ """Enumerate all active memberships."""
+ # Each collection is cast into a list before chaining to ensure that it does not
+ # change during processing (if, for example, membership is revoked or replaced).
+ return itertools.chain(
+ *(list(getattr(self, attr)) for attr in self.__active_membership_attrs__)
+ )
+
+ def has_any_memberships(self) -> bool:
+ """
+ Test for any non-invite membership records that must be preserved.
+
+ This is used to test for whether the account is safe to purge (hard delete) from
+ the database. If non-invite memberships are present, the account cannot be
+ purged as immutable records must be preserved. Instead, the account must be put
+ into DELETED state with all PII scrubbed.
+ """
+ return any(
+ db.session.query(getattr(self, attr).exists()).scalar()
+ for attr in self.__noninvite_membership_attrs__
+ )
+
+ # --- Transport details
+
+ @with_roles(call={'owner'})
+ def has_transport_email(self) -> bool:
+ """User has an email transport address."""
+ return self.state.ACTIVE and bool(self.email)
+
+ @with_roles(call={'owner'})
+ def has_transport_sms(self) -> bool:
+ """User has an SMS transport address."""
+ return (
+ self.state.ACTIVE
+ and self.phone != ''
+ and self.phone.phone_number.has_sms is not False
+ )
+
+ @with_roles(call={'owner'})
+ def has_transport_webpush(self) -> bool: # TODO # pragma: no cover
+ """User has a webpush transport address."""
+ return False
+
+ @with_roles(call={'owner'})
+ def has_transport_telegram(self) -> bool: # TODO # pragma: no cover
+ """User has a Telegram transport address."""
+ return False
+
+ @with_roles(call={'owner'})
+ def has_transport_whatsapp(self) -> bool:
+ """User has a WhatsApp transport address."""
+ return (
+ self.state.ACTIVE
+ and self.phone != ''
+ and self.phone.phone_number.has_wa is not False
+ )
+
+ @with_roles(call={'owner'})
+ def transport_for_email(self, context: Model | None = None) -> AccountEmail | None:
+ """Return user's preferred email address within a context."""
+ # TODO: Per-account/project customization is a future option
+ if self.state.ACTIVE:
+ return self.email or None
+ return None
+
+ @with_roles(call={'owner'})
+ def transport_for_sms(self, context: Model | None = None) -> AccountPhone | None:
+ """Return user's preferred phone number within a context."""
+ # TODO: Per-account/project customization is a future option
+ if (
+ self.state.ACTIVE
+ and self.phone != ''
+ and self.phone.phone_number.has_sms is not False
+ ):
+ return self.phone
+ return None
+
+ @with_roles(call={'owner'})
+ def transport_for_webpush(
+ self, context: Model | None = None
+ ): # TODO # pragma: no cover
+ """Return user's preferred webpush transport address within a context."""
+ return None
+
+ @with_roles(call={'owner'})
+ def transport_for_telegram(
+ self, context: Model | None = None
+ ): # TODO # pragma: no cover
+ """Return user's preferred Telegram transport address within a context."""
+ return None
+
+ @with_roles(call={'owner'})
+ def transport_for_whatsapp(self, context: Model | None = None):
+ """Return user's preferred WhatsApp transport address within a context."""
+ # TODO: Per-account/project customization is a future option
+ if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.allow_wa:
+ return self.phone
+ return None
+
+ @with_roles(call={'owner'})
+ def transport_for_signal(self, context: Model | None = None):
+ """Return user's preferred Signal transport address within a context."""
+ # TODO: Per-account/project customization is a future option
+ if self.state.ACTIVE and self.phone != '' and self.phone.phone_number.allow_sm:
+ return self.phone
+ return None
+
+ @with_roles(call={'owner'})
+ def has_transport(self, transport: str) -> bool:
+ """
+ Verify if user has a given transport address.
+
+ Helper method to call ``self.has_transport_<{tag}>{html_escape(self.text)}{tag}>
' - return f'{html_escape(self.text)}
' + return Markup(f'<{tag}>{html_escape(self.text)}{tag}>
') + return Markup(f'{html_escape(self.text)}
') + + def __html_format__(self, format_spec: str) -> str: + """Implement format_spec support as required by Markup.""" + return self.__html__().__html_format__(format_spec) @property def html(self) -> Markup: return Markup(self.__html__()) - def __json__(self) -> Dict[str, Any]: + def __json__(self) -> dict[str, Any]: """Return JSON-compatible rendering of contents.""" return {'text': self.text, 'html': self.__html__()} @@ -548,7 +545,7 @@ def __json__(self) -> Dict[str, Any]: class ImgeeFurl(furl): """Furl with a resize method specifically for Imgee URLs.""" - def resize(self, width: int, height: Optional[int] = None) -> furl: + def resize(self, width: int, height: int | None = None) -> furl: """ Return image url with `?size=WxH` suffixed to it. @@ -568,7 +565,7 @@ class ImgeeType(UrlType): # pylint: disable=abstract-method url_parser = ImgeeFurl cache_ok = True - def process_bind_param(self, value, dialect): + def process_bind_param(self, value: Any, dialect: Any) -> furl: value = super().process_bind_param(value, dialect) if value: allowed_domains = app.config.get('IMAGE_URL_DOMAINS', []) @@ -583,68 +580,82 @@ def process_bind_param(self, value, dialect): return value +_MC = TypeVar('_MC', bound='MarkdownCompositeBase') + + class MarkdownCompositeBase(MutableComposite): """Represents Markdown text and rendered HTML as a composite column.""" config: ClassVar[MarkdownConfig] - def __init__(self, text, html=None): + def __init__(self, text: str | None, html: str | None = None) -> None: """Create a composite.""" if html is None: self.text = text # This will regenerate HTML else: self._text = text - self._html = html + self._html: str | None = html - # Return column values for SQLAlchemy to insert into the database - def __composite_values__(self): - """Return composite values.""" + def __composite_values__(self) -> tuple[str | None, str | None]: + """Return composite values for SQLAlchemy.""" return (self._text, self._html) # Return a string representation of the text (see class decorator) - def __str__(self): + def __str__(self) -> str: """Return string representation.""" return self._text or '' - def __markdown__(self): + def __markdown__(self) -> str: """Return source Markdown (for escaper).""" return self._text or '' - # Return a HTML representation of the text - def __html__(self): + def __markdown_format__(self, format_spec: str) -> str: + """Implement format_spec support as required by MarkdownString.""" + # This call's MarkdownString's __format__ instead of __markdown_format__ as the + # content has not been manipulated from the source string + return self.__markdown__().__format__(format_spec) + + def __html__(self) -> str: """Return HTML representation.""" return self._html or '' + def __html_format__(self, format_spec: str) -> str: + """Implement format_spec support as required by Markup.""" + # This call's Markup's __format__ instead of __html_format__ as the + # content has not been manipulated from the source string + return self.__html__().__format__(format_spec) + # Return a Markup string of the HTML @property - def html(self): + def html(self) -> Markup | None: """Return HTML as a read-only property.""" return Markup(self._html) if self._html is not None else None @property - def text(self): + def text(self) -> str | None: """Return text as a property.""" return self._text @text.setter - def text(self, value): + def text(self, value: str | None) -> None: """Set the text value.""" self._text = None if value is None else str(value) self._html = self.config.render(self._text) self.changed() - def __json__(self) -> Dict[str, Optional[str]]: + def __json__(self) -> dict[str, str | None]: """Return JSON-compatible rendering of composite.""" return {'text': self._text, 'html': self._html} - # Compare text value - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Compare for equality.""" - return isinstance(other, self.__class__) and ( - self.__composite_values__() == other.__composite_values__() + return ( + isinstance(other, self.__class__) + and (self.__composite_values__() == other.__composite_values__()) + or self._text == other ) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: """Compare for inequality.""" return not self.__eq__(other) @@ -652,37 +663,62 @@ def __ne__(self, other): # tested here as we don't use them. # https://docs.sqlalchemy.org/en/13/orm/extensions/mutable.html#id1 - def __getstate__(self): + def __getstate__(self) -> tuple[str | None, str | None]: """Get state for pickling.""" # Return state for pickling return (self._text, self._html) - def __setstate__(self, state): + def __setstate__(self, state: tuple[str | None, str | None]) -> None: """Set state from pickle.""" # Set state from pickle self._text, self._html = state self.changed() - def __bool__(self): + def __bool__(self) -> bool: """Return boolean value.""" return bool(self._text) @classmethod - def coerce(cls, key, value): + def coerce(cls: type[_MC], key: str, value: Any) -> _MC: """Allow a composite column to be assigned a string value.""" return cls(value) + # TODO: Add `nullable` as a keyword parameter and add overloads for returning + # Mapped[str] or Mapped[str | None] based on nullable + @classmethod def create( - cls, name: str, deferred: bool = False, group: Optional[str] = None, **kwargs - ): + cls: type[_MC], + name: str, + deferred: bool = False, + deferred_group: str | None = None, + **kwargs, + ) -> tuple[sa.orm.Composite[_MC], Mapped[str], Mapped[str]]: """Create a composite column and backing individual columns.""" - return composite( - cls, - sa.Column(name + '_text', sa.UnicodeText, **kwargs), - sa.Column(name + '_html', sa.UnicodeText, **kwargs), + col_text = sa.orm.mapped_column( + name + '_text', + sa.UnicodeText, deferred=deferred, - group=group or name, + deferred_group=deferred_group, + **kwargs, + ) + col_html = sa.orm.mapped_column( + name + '_html', + sa.UnicodeText, + deferred=deferred, + deferred_group=deferred_group, + **kwargs, + ) + return ( + composite( + cls, + col_text, + col_html, + deferred=deferred, + group=deferred_group, + ), + col_text, + col_html, ) diff --git a/funnel/models/label.py b/funnel/models/label.py index b7ace4cdc..cb586b160 100644 --- a/funnel/models/label.py +++ b/funnel/models/label.py @@ -2,15 +2,19 @@ from __future__ import annotations -from typing import Union -from uuid import UUID # noqa: F401 # pylint: disable=unused-import - -from sqlalchemy.ext.orderinglist import ordering_list -from sqlalchemy.sql import exists +from sqlalchemy.ext.orderinglist import OrderingList, ordering_list from coaster.sqlalchemy import with_roles -from . import BaseScopedNameMixin, Mapped, TSVectorType, db, hybrid_property, sa +from . import ( + BaseScopedNameMixin, + Mapped, + Model, + TSVectorType, + hybrid_property, + relationship, + sa, +) from .helpers import add_search_trigger, reopen, visual_field_delimiter from .project import Project from .project_membership import project_child_role_map @@ -18,7 +22,7 @@ proposal_label = sa.Table( 'proposal_label', - db.Model.metadata, # type: ignore[has-type] + Model.metadata, sa.Column( 'proposal_id', sa.Integer, @@ -38,19 +42,15 @@ ) -class Label( - BaseScopedNameMixin, - db.Model, # type: ignore[name-defined] -): +class Label(BaseScopedNameMixin, Model): __tablename__ = 'label' - __allow_unmapped__ = True - project_id = sa.Column( + project_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False ) # Backref from project is defined in the Project model with an ordering list project: Mapped[Project] = with_roles( - sa.orm.relationship(Project), grants_via={None: project_child_role_map} + relationship(Project), grants_via={None: project_child_role_map} ) # `parent` is required for # :meth:`~coaster.sqlalchemy.mixins.BaseScopedNameMixin.make_name()` @@ -59,16 +59,18 @@ class Label( #: Parent label's id. Do not write to this column directly, as we don't have the #: ability to : validate the value within the app. Always use the :attr:`main_label` #: relationship. - main_label_id = sa.Column( + main_label_id = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('label.id', ondelete='CASCADE'), index=True, nullable=True, ) + main_label: Mapped[Label] = relationship( + remote_side='Label.id', back_populates='options' + ) # See https://docs.sqlalchemy.org/en/13/orm/self_referential.html - options = sa.orm.relationship( - 'Label', - backref=sa.orm.backref('main_label', remote_side='Label.id'), + options: Mapped[OrderingList[Label]] = relationship( + back_populates='main_label', order_by='Label.seq', passive_deletes=True, collection_class=ordering_list('seq', count_from=1), @@ -79,48 +81,53 @@ class Label( # add_primary_relationship) #: Sequence number for this label, used in UI for ordering - seq = sa.Column(sa.Integer, nullable=False) + seq = sa.orm.mapped_column(sa.Integer, nullable=False) # A single-line description of this label, shown when picking labels (optional) - description = sa.Column(sa.UnicodeText, nullable=False, default='') + description = sa.orm.mapped_column(sa.UnicodeText, nullable=False, default='') #: Icon for displaying in space-constrained UI. Contains one emoji symbol. #: Since emoji can be composed from multiple symbols, there is no length #: limit imposed here - icon_emoji = sa.Column(sa.UnicodeText, nullable=True) + icon_emoji = sa.orm.mapped_column(sa.UnicodeText, nullable=True) #: Restricted mode specifies that this label may only be applied by someone with #: an editorial role (TODO: name the role). If this label is a parent, it applies #: to all its children - _restricted = sa.Column('restricted', sa.Boolean, nullable=False, default=False) + _restricted = sa.orm.mapped_column( + 'restricted', sa.Boolean, nullable=False, default=False + ) #: Required mode signals to UI that if this label is a parent, one of its #: children must be mandatorily applied to the proposal. The value of this #: field must be ignored if the label is not a parent - _required = sa.Column('required', sa.Boolean, nullable=False, default=False) + _required = sa.orm.mapped_column( + 'required', sa.Boolean, nullable=False, default=False + ) #: Archived mode specifies that the label is no longer available for use #: although all the previous records will stay in database. - _archived = sa.Column('archived', sa.Boolean, nullable=False, default=False) + _archived = sa.orm.mapped_column( + 'archived', sa.Boolean, nullable=False, default=False + ) - search_vector: Mapped[TSVectorType] = sa.orm.deferred( - sa.Column( - TSVectorType( - 'name', - 'title', - 'description', - weights={'name': 'A', 'title': 'A', 'description': 'B'}, - regconfig='english', - hltext=lambda: sa.func.concat_ws( - visual_field_delimiter, Label.title, Label.description - ), + search_vector: Mapped[TSVectorType] = sa.orm.mapped_column( + TSVectorType( + 'name', + 'title', + 'description', + weights={'name': 'A', 'title': 'A', 'description': 'B'}, + regconfig='english', + hltext=lambda: sa.func.concat_ws( + visual_field_delimiter, Label.title, Label.description ), - nullable=False, - ) + ), + nullable=False, + deferred=True, ) #: Proposals that this label is attached to - proposals: Mapped[Proposal] = sa.orm.relationship( + proposals: Mapped[list[Proposal]] = relationship( Proposal, secondary=proposal_label, back_populates='labels' ) @@ -183,45 +190,51 @@ def restricted(self) -> bool: # pylint: disable=protected-access return self.main_label._restricted if self.main_label else self._restricted - @restricted.setter - def restricted(self, value: bool) -> None: + @restricted.inplace.setter + def _restricted_setter(self, value: bool) -> None: if self.main_label: raise ValueError("This flag must be set on the parent") self._restricted = value - @restricted.expression - def restricted(cls): # pylint: disable=no-self-argument + @restricted.inplace.expression + @classmethod + def _restricted_expression(cls) -> sa.Case: + """Return SQL Expression.""" return sa.case( ( - cls.main_label_id.isnot(None), + cls.main_label_id.is_not(None), sa.select(Label._restricted) .where(Label.id == cls.main_label_id) - .as_scalar(), + .scalar_subquery(), ), else_=cls._restricted, ) @hybrid_property def archived(self) -> bool: + """Test if this label or parent label is archived.""" return self._archived or ( self.main_label._archived # pylint: disable=protected-access if self.main_label else False ) - @archived.setter - def archived(self, value: bool) -> None: + @archived.inplace.setter + def _archived_setter(self, value: bool) -> None: + """Archive this label.""" self._archived = value - @archived.expression - def archived(cls): # pylint: disable=no-self-argument + @archived.inplace.expression + @classmethod + def _archived_expression(cls) -> sa.Case: + """Return SQL Expression.""" return sa.case( (cls._archived.is_(True), cls._archived), ( - cls.main_label_id.isnot(None), + cls.main_label_id.is_not(None), sa.select(Label._archived) .where(Label.id == cls.main_label_id) - .as_scalar(), + .scalar_subquery(), ), else_=cls._archived, ) @@ -230,9 +243,11 @@ def archived(cls): # pylint: disable=no-self-argument def has_options(self) -> bool: return bool(self.options) - @has_options.expression - def has_options(cls): # pylint: disable=no-self-argument - return exists().where(Label.main_label_id == cls.id) + @has_options.inplace.expression + @classmethod + def _has_options_expression(cls) -> sa.Exists: + """Return SQL Expression.""" + return sa.exists().where(Label.main_label_id == cls.id) @property def is_main_label(self) -> bool: @@ -243,8 +258,8 @@ def required(self) -> bool: # pylint: disable=using-constant-test return self._required if self.has_options else False - @required.setter - def required(self, value: bool) -> None: + @required.inplace.setter + def _required_setter(self, value: bool) -> None: if value and not self.has_options: raise ValueError("Labels without options cannot be mandatory") self._required = value @@ -308,7 +323,7 @@ class ProposalLabelProxyWrapper: def __init__(self, obj: Proposal) -> None: object.__setattr__(self, '_obj', obj) - def __getattr__(self, name: str) -> Union[bool, str, None]: + def __getattr__(self, name: str) -> bool | str | None: """Get an attribute.""" # What this does: # 1. Check if the project has this label (including archived labels). If not, @@ -372,9 +387,7 @@ def __setattr__(self, name: str, value: bool) -> None: class ProposalLabelProxy: - def __get__( - self, obj, cls=None - ) -> Union[ProposalLabelProxyWrapper, ProposalLabelProxy]: + def __get__(self, obj, cls=None) -> ProposalLabelProxyWrapper | ProposalLabelProxy: """Get proposal label proxy.""" if obj is not None: return ProposalLabelProxyWrapper(obj) @@ -383,7 +396,7 @@ def __get__( @reopen(Project) class __Project: - labels = sa.orm.relationship( + labels: Mapped[list[Label]] = relationship( Label, primaryjoin=sa.and_( Label.project_id == Project.id, @@ -393,7 +406,7 @@ class __Project: order_by=Label.seq, viewonly=True, ) - all_labels = sa.orm.relationship( + all_labels: Mapped[list[Label]] = relationship( Label, collection_class=ordering_list('seq', count_from=1), back_populates='project', @@ -405,9 +418,7 @@ class __Proposal: #: For reading and setting labels from the edit form formlabels = ProposalLabelProxy() - labels = with_roles( - sa.orm.relationship( - Label, secondary=proposal_label, back_populates='proposals' - ), + labels: Mapped[list[Label]] = with_roles( + relationship(Label, secondary=proposal_label, back_populates='proposals'), read={'all'}, ) diff --git a/funnel/models/login_session.py b/funnel/models/login_session.py new file mode 100644 index 000000000..e1d628cc5 --- /dev/null +++ b/funnel/models/login_session.py @@ -0,0 +1,190 @@ +"""Model for a user's auth (login) session.""" + +from __future__ import annotations + +from datetime import timedelta + +from coaster.utils import utcnow + +from ..signals import session_revoked +from . import ( + BaseMixin, + DynamicMapped, + Mapped, + Model, + UuidMixin, + backref, + relationship, + sa, +) +from .account import Account +from .helpers import reopen + +__all__ = [ + 'LoginSession', + 'LoginSessionError', + 'LoginSessionExpiredError', + 'LoginSessionRevokedError', + 'LoginSessionInactiveUserError', + 'auth_client_login_session', + 'LOGIN_SESSION_VALIDITY_PERIOD', +] + + +class LoginSessionError(Exception): + """Base exception for user session errors.""" + + +class LoginSessionExpiredError(LoginSessionError): + """This user session has expired and cannot be marked as currently active.""" + + +class LoginSessionRevokedError(LoginSessionError): + """This user session has been revoked and cannot be marked as currently active.""" + + +class LoginSessionInactiveUserError(LoginSessionError): + """This user is not in ACTIVE state and cannot have a currently active session.""" + + +LOGIN_SESSION_VALIDITY_PERIOD = timedelta(days=365) + +#: When a user logs into an client app, the user's session is logged against +#: the client app in this table +auth_client_login_session = sa.Table( + 'auth_client_login_session', + Model.metadata, + sa.Column( + 'auth_client_id', + sa.Integer, + sa.ForeignKey('auth_client.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'login_session_id', + sa.Integer, + sa.ForeignKey('login_session.id'), + nullable=False, + primary_key=True, + ), + sa.Column( + 'created_at', + sa.TIMESTAMP(timezone=True), + nullable=False, + default=sa.func.utcnow(), + ), + sa.Column( + 'accessed_at', + sa.TIMESTAMP(timezone=True), + nullable=False, + default=sa.func.utcnow(), + ), +) + + +class LoginSession(UuidMixin, BaseMixin, Model): + __tablename__ = 'login_session' + + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) + account: Mapped[Account] = relationship( + Account, + backref=backref('all_login_sessions', cascade='all', lazy='dynamic'), + ) + + #: User's last known IP address + ipaddr = sa.orm.mapped_column(sa.String(45), nullable=False) + #: City geonameid from IP address + geonameid_city = sa.orm.mapped_column(sa.Integer, nullable=True) + #: State/subdivision geonameid from IP address + geonameid_subdivision = sa.orm.mapped_column(sa.Integer, nullable=True) + #: Country geonameid from IP address + geonameid_country = sa.orm.mapped_column(sa.Integer, nullable=True) + #: User's network, from IP address + geoip_asn = sa.orm.mapped_column(sa.Integer, nullable=True) + #: User agent + user_agent = sa.orm.mapped_column(sa.UnicodeText, nullable=False) + #: The login service that was used to make this session + login_service = sa.orm.mapped_column(sa.Unicode, nullable=True) + + accessed_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=False) + revoked_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + sudo_enabled_at = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() + ) + + def __repr__(self) -> str: + """Represent :class:`UserSession` as a string.""" + return f'- {% trans ipaddr=user_session.ipaddr %}{{ location }} – estimated from {{ ipaddr }}{% endtrans %} + {% trans ipaddr=login_session.ipaddr %}{{ location }} – estimated from {{ ipaddr }}{% endtrans %}
{%- trans %}{% endtrans %}
- {% trans %}Delete my account{% endtrans %} +{% trans -%} + If you no longer need this account, you can delete it. If you have a duplicate account, you can merge it by adding the same phone number or email address here. No deletion necessary. + {%- endtrans %}
+- {{ orgmem.organization.profile.title }} - {% if not orgmem.organization.profile.state.PUBLIC %} + {{ orgmem.account.title }} + {% if not orgmem.account.profile_state.PUBLIC %} {{ faicon(icon='lock-alt', icon_size='caption', baseline=false, css_class="margin-left") }} {% endif %}
@@ -61,7 +61,7 @@- {%- for user in orgmem.organization.admin_users %} + {%- for user in orgmem.account.admin_users %} {{ user.pickername }} {%- if not loop.last %},{% endif %} {%- endfor %}
diff --git a/funnel/templates/account_saved.html.jinja2 b/funnel/templates/account_saved.html.jinja2 index 633fd3654..8a14ddf83 100644 --- a/funnel/templates/account_saved.html.jinja2 +++ b/funnel/templates/account_saved.html.jinja2 @@ -28,5 +28,5 @@ {% endblock basecontent %} {% block footerscripts %} - + {% endblock footerscripts %} diff --git a/funnel/templates/ajaxform.html.jinja2 b/funnel/templates/ajaxform.html.jinja2 index 3a960164f..ae84b8fe7 100644 --- a/funnel/templates/ajaxform.html.jinja2 +++ b/funnel/templates/ajaxform.html.jinja2 @@ -1,5 +1,5 @@{% trans %}The following users have permissions to this app{% endtrans %}
- {% else %} -{% trans %}The following teams have permissions to this app{% endtrans %}
- {% endif %} +{% trans %}The following users have permissions to this app{% endtrans %}
- {{ view.reason_email }} - • - {% trans %}Unsubscribe or manage preferences{% endtrans %} -
- {%- endif %} - {% endblock footer %}+ + | +
+ | + + ++ | + + + + ++ | + +|
+ + |
+ {#
+ Set the email width. Defined in two places:
+ 1. max-width for all clients except Desktop Windows Outlook, allowing the email to squish on narrow but never go wider than 600px.
+ 2. MSO tags for Desktop Windows Outlook enforce a 600px width.
+ #}
+
+
+
+
+ {# Email content : BEGIN #}
+
+
|
+
+ + | |
+ | + + ++ | + + + ++ | + +
+ {% trans %}Hasgeek Learning Private Limited{% endtrans %} + {% trans %}Need help?{% endtrans %} {{ config['SITE_SUPPORT_EMAIL'] }} • {{ config['SITE_SUPPORT_PHONE_FORMATTED'] }} + {%- if view %}{# Notification view #} + + {% if view.reason_email %} {{ view.reason_email }} • {% endif %}{% trans %}Unsubscribe or manage preferences{% endtrans %} + {%- endif %} + |
+