diff --git a/.gitignore b/.gitignore index ca291c340..546e6a6ac 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.egg-info *.pyc .coverage +coverage/ +coverage.xml .eslintcache .mypy_cache .pytest_cache @@ -46,7 +48,6 @@ tests/cypress/screenshots tests/cypress/videos tests/screenshots tests/unit/utils/markdown/data/output.html -coverage/ # Instance files that should not be checked-in instance/development.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c389cbabf..0a038c7fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,6 +12,7 @@ ci: 'no-commit-to-branch', # 'hadolint-docker', 'docker-compose-check', + 'mypy', # Runs as a local hook now ] repos: - repo: https://github.com/pre-commit-ci/pre-commit-ci-config @@ -51,7 +52,7 @@ repos: - id: pyupgrade args: ['--keep-runtime-typing', '--py311-plus'] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.9 + rev: v0.1.11 hooks: - id: ruff args: ['--fix', '--exit-non-zero-on-fix'] @@ -100,28 +101,8 @@ repos: rev: 23.12.1 hooks: - id: black - # Mypy is temporarily disabled until the SQLAlchemy 2.0 migration is complete - # - repo: https://github.com/pre-commit/mirrors-mypy - # rev: v1.3.0 - # hooks: - # - id: mypy - # # warn-unused-ignores is unsafe with pre-commit, see - # # https://github.com/python/mypy/issues/2960 - # args: ['--no-warn-unused-ignores', '--ignore-missing-imports'] - # additional_dependencies: - # - flask - # - lxml-stubs - # - sqlalchemy - # - toml - # - tomli - # - types-chevron - # - types-geoip2 - # - types-python-dateutil - # - types-pytz - # - types-requests - # - typing-extensions - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 + rev: 7.0.0 hooks: - id: flake8 additional_dependencies: *flake8deps @@ -149,23 +130,12 @@ repos: rev: v3.0.0 hooks: - id: creosote + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.9.0.6 + hooks: + - id: shellcheck args: - - --venv=.venv - - --path=funnel - - --path=tests - - --path=migrations/versions - - --deps-file=requirements/base.in - - --exclude-dep=argon2-cffi # Optional dep for passlib - - --exclude-dep=bcrypt # Optional dep for passlib - - --exclude-dep=greenlet # Optional dep for SQLAlchemy's asyncio support - - --exclude-dep=gunicorn # Not imported, used as server - - --exclude-dep=linkify-it-py # Optional dep for markdown-it-py - - --exclude-dep=psycopg # Optional dep for SQLAlchemy - - --exclude-dep=rq-dashboard # Creosote fails to recognise the import - - --exclude-dep=tzdata # Data-only dep, therefore no import statement - - --exclude-dep=urllib3 # Required to silence a pip-audit warning - - --exclude-dep=wtforms-sqlalchemy # Temp dep on an unreleased git branch - + - --external-sources - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: @@ -231,3 +201,16 @@ repos: rev: v3.0.1 hooks: - id: docker-compose-check + - repo: local + hooks: + - id: mypy + name: mypy + entry: .venv/bin/mypy + args: + - --no-warn-unused-ignores + - --no-warn-redundant-casts + - . # Required to honour settings in pyproject.toml + language: system + types_or: [python, pyi] + require_serial: true + pass_filenames: false # Mypy is unreliable if module import order varies diff --git a/.testenv b/.testenv index f60567fe7..12a9accf4 100644 --- a/.testenv +++ b/.testenv @@ -21,7 +21,7 @@ FLASK_LOG_TELEGRAM_APIKEY=null FLASK_LOG_SLACK_WEBHOOKS='[]' # Run RQ jobs inline in tests FLASK_RQ_ASYNC=false -# Recaptcha keys from https://developers.google.com/recaptcha/docfaq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do +# Recaptcha keys from https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do FLASK_RECAPTCHA_USE_SSL=true FLASK_RECAPTCHA_PUBLIC_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI FLASK_RECAPTCHA_PRIVATE_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe diff --git a/docker/entrypoints/ci-test.sh b/docker/entrypoints/ci-test.sh index 4da374e54..ad996e1fa 100755 --- a/docker/entrypoints/ci-test.sh +++ b/docker/entrypoints/ci-test.sh @@ -1,5 +1,5 @@ #!/bin/sh make install-test -pytest --allow-hosts=127.0.0.1,::1,$(hostname -i),$(getent ahosts db-test | awk '/STREAM/ { print $1}'),$(getent ahosts redis-test | awk '/STREAM/ { print $1}') --gherkin-terminal-reporter -vv --showlocals --cov=funnel +pytest "--allow-hosts=127.0.0.1,::1,$(hostname -i),$(getent ahosts db-test | awk '/STREAM/ { print $1}'),$(getent ahosts redis-test | awk '/STREAM/ { print $1}')" --gherkin-terminal-reporter -vv --showlocals --cov=funnel coverage lcov -o coverage/funnel.lcov diff --git a/docker/entrypoints/dev.sh b/docker/entrypoints/dev.sh index 9135c8ff1..a1930f1ef 100755 --- a/docker/entrypoints/dev.sh +++ b/docker/entrypoints/dev.sh @@ -1,6 +1,6 @@ #!/bin/bash -if [ "$(psql -XtA -U postgres -h $DB_HOST funnel -c "select count(*) from information_schema.tables where table_schema = 'public';")" = "0" ]; then +if [ "$(psql -XtA -U postgres -h "$DB_HOST" funnel -c "select count(*) from information_schema.tables where table_schema = 'public';")" = "0" ]; then flask dbcreate flask db stamp fi diff --git a/funnel/__init__.py b/funnel/__init__.py index 210bd508e..534228ceb 100644 --- a/funnel/__init__.py +++ b/funnel/__init__.py @@ -89,7 +89,7 @@ # present, it is loaded in debug and testing modes only for each_app in all_apps: coaster.app.init_app( - each_app, ['py', 'env'], env_prefix=['FLASK', f'APP_{each_app.name.upper()}'] + each_app, ['env'], env_prefix=['FLASK', f'APP_{each_app.name.upper()}'] ) # Force specific config settings, overriding deployment config @@ -147,7 +147,7 @@ baseframe.init_app( app, requires=['funnel'], - theme='funnel', # type: ignore[arg-type] + theme='funnel', # type: ignore[arg-type] # FIXME error_handlers=False, ) @@ -162,7 +162,7 @@ transports.init() # Register JS and CSS assets on both apps -app.assets.register( # type: ignore[attr-defined] +app.assets.register( # type: ignore[attr-defined] # FIXME 'js_fullcalendar', Bundle( assets.require( @@ -178,7 +178,7 @@ filters='rjsmin', ), ) -app.assets.register( # type: ignore[attr-defined] +app.assets.register( # type: ignore[attr-defined] # FIXME 'css_fullcalendar', Bundle( assets.require('jquery.fullcalendar.css', 'spectrum.css'), @@ -186,7 +186,7 @@ filters='cssmin', ), ) -app.assets.register( # type: ignore[attr-defined] +app.assets.register( # type: ignore[attr-defined] # FIXME 'js_schedules', Bundle( assets.require('schedules.js'), @@ -199,12 +199,9 @@ # --- 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 -) -app.wsgi_app.add_files( # type: ignore[attr-defined] - baseframe.static_folder, prefix=baseframe.static_url_path -) +_wn = WhiteNoise(app.wsgi_app, root=app.static_folder, prefix=app.static_url_path) +_wn.add_files(baseframe.static_folder, prefix=baseframe.static_url_path) +app.wsgi_app = _wn # type: ignore[method-assign] # --- Init SQLAlchemy mappers ---------------------------------------------------------- diff --git a/funnel/auth.py b/funnel/auth.py new file mode 100644 index 000000000..d43f2e3d9 --- /dev/null +++ b/funnel/auth.py @@ -0,0 +1,41 @@ +"""Auth proxy.""" + +from coaster.auth import ( + CurrentAuth as CurrentAuthBase, + GetCurrentAuth, + add_auth_anchor, + add_auth_attribute, + request_has_auth, +) + +from . import all_apps +from .models import User + +__all__ = [ + 'CurrentAuth', + 'add_auth_attribute', + 'add_auth_anchor', + 'current_auth', + 'request_has_auth', +] + + +class CurrentAuth(CurrentAuthBase): + """CurrentAuth for Funnel.""" + + # These attrs are typed as not-optional because they're typically accessed in a view + # that is already gated with the `@requires_login` or related decorator, so they're + # guaranteed to be present within the view. However, this will require a type-ignore + # for any code that tests `if current_auth.actor`, so those will need a rewrite to + # `if current_auth`. When auth clients become supported actors, this may need some + # form of PEP 647 typeguard to identify the actor's exact type. + + user: User + actor: User + + +current_auth = GetCurrentAuth.proxy(CurrentAuth) + +# Install this proxy in all apps, overriding the proxy provided by coaster.app.init_app +for _app in all_apps: + _app.jinja_env.globals['current_auth'] = current_auth diff --git a/funnel/cli/geodata.py b/funnel/cli/geodata.py index 978f03850..839d29b1b 100644 --- a/funnel/cli/geodata.py +++ b/funnel/cli/geodata.py @@ -167,7 +167,7 @@ def load_country_info(filename: str) -> None: GeoCountryInfo.query.all() # Load everything into session cache for item in countryinfo: if item.geonameid: - ci = GeoCountryInfo.query.get(int(item.geonameid)) + ci = db.session.get(GeoCountryInfo, int(item.geonameid)) if ci is None: ci = GeoCountryInfo(geonameid=int(item.geonameid)) db.session.add(ci) @@ -276,7 +276,7 @@ def load_geonames(filename: str) -> None: for item in rich.progress.track(geonames): if item.geonameid: - gn = GeoName.query.get(int(item.geonameid)) + gn = db.session.get(GeoName, int(item.geonameid)) if gn is None: gn = GeoName(geonameid=int(item.geonameid)) db.session.add(gn) @@ -335,7 +335,7 @@ def load_alt_names(filename: str) -> None: for item in rich.progress.track(altnames): if item.geonameid: - rec = GeoAltName.query.get(int(item.id)) + rec = db.session.get(GeoAltName, int(item.id)) if rec is None: rec = GeoAltName(id=int(item.id)) db.session.add(rec) @@ -368,7 +368,7 @@ def load_admin1_codes(filename: str) -> None: GeoAdmin1Code.query.all() # Load all data into session cache for faster lookup for item in rich.progress.track(admincodes): if item.geonameid: - rec = GeoAdmin1Code.query.get(item.geonameid) + rec = db.session.get(GeoAdmin1Code, item.geonameid) if rec is None: rec = GeoAdmin1Code(geonameid=int(item.geonameid)) db.session.add(rec) @@ -397,7 +397,7 @@ def load_admin2_codes(filename: str) -> None: GeoAdmin2Code.query.all() # Load all data into session cache for faster lookup for item in rich.progress.track(admincodes): if item.geonameid: - rec = GeoAdmin2Code.query.get(item.geonameid) + rec = db.session.get(GeoAdmin2Code, item.geonameid) if rec is None: rec = GeoAdmin2Code(geonameid=int(item.geonameid)) db.session.add(rec) diff --git a/funnel/cli/refresh/markdown.py b/funnel/cli/refresh/markdown.py index 2fa846732..544883b5e 100644 --- a/funnel/cli/refresh/markdown.py +++ b/funnel/cli/refresh/markdown.py @@ -9,16 +9,19 @@ import rich.progress from ... import models -from ...models import MarkdownModelUnion, db, sa_orm +from ...models import db, sa_orm from . import refresh -_M = TypeVar('_M', bound=MarkdownModelUnion) +_M = TypeVar('_M', bound=models.ModelIdProtocol) class MarkdownModel(Generic[_M]): """Holding class for a model that has markdown fields with custom configuration.""" + #: Dict of ``{MarkdownModel().name: MarkdownModel()}`` registry: ClassVar[dict[str, MarkdownModel]] = {} + #: Dict of ``{config_name: MarkdownModel()}``, where the fields on the model using + #: that config are enumerated in :attr:`config_fields` config_registry: ClassVar[dict[str, set[MarkdownModel]]] = {} def __init__(self, model: type[_M], fields: set[str]) -> None: @@ -54,12 +57,12 @@ def reparse(self, config: str | None = None, obj: _M | None = None) -> None: iter_total = 1 else: load_columns = ( - [self.model.id] + [self.model.id_] + [getattr(self.model, f'{field}_text'.lstrip('_')) for field in fields] + [getattr(self.model, f'{field}_html'.lstrip('_')) for field in fields] ) iter_list = ( - self.model.query.order_by(self.model.id) + self.model.query.order_by(self.model.id_) .options(sa_orm.load_only(*load_columns)) .yield_per(10) ) diff --git a/funnel/devtest.py b/funnel/devtest.py index e3bfea795..6c5117eb4 100644 --- a/funnel/devtest.py +++ b/funnel/devtest.py @@ -18,7 +18,7 @@ from flask import Flask -from . import app as main_app, shortlinkapp, transports, unsubscribeapp +from . import all_apps, app as main_app, transports from .models import db from .typing import ReturnView @@ -100,7 +100,7 @@ def __call__(self, environ: Any, start_response: Any) -> Iterable[bytes]: return use_app(environ, start_response) -devtest_app = AppByHostWsgi(main_app, shortlinkapp, unsubscribeapp) +devtest_app = AppByHostWsgi(*all_apps) # --- Background worker ---------------------------------------------------------------- @@ -143,7 +143,7 @@ def _signature_without_annotations(func) -> inspect.Signature: ) -def install_mock(func: Callable, mock: Callable) -> None: +def install_mock(func: Any, mock: Any) -> None: """ Patch all existing references to :attr:`func` with :attr:`mock`. @@ -161,9 +161,9 @@ 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] # Typeshed says not callable + refs = gc.get_referrers(func()) # Recover func from the weakref so we can do an `is` match in referrers - func = func() # type: ignore[misc] + func = func() for ref in refs: if isinstance(ref, dict): # We have a namespace dict. Iterate through contents to find the reference @@ -312,7 +312,7 @@ def start(self) -> None: raise RuntimeError(f"Server exited with code {self._process.exitcode}") def _is_ready(self) -> bool: - """Probe for readyness with a socket connection.""" + """Probe for readiness with a socket connection.""" if not self.probe_at: return False sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/funnel/forms/account.py b/funnel/forms/account.py index 1182ffd3e..a749863b4 100644 --- a/funnel/forms/account.py +++ b/funnel/forms/account.py @@ -197,7 +197,7 @@ class PasswordPolicyForm(forms.Form): ) def validate_password(self, field: forms.Field) -> None: - """Test password strength and save resuls (no errors raised).""" + """Test password strength and save results (no errors raised).""" user_inputs = [] if self.edit_user: @@ -283,6 +283,7 @@ class PasswordResetForm(forms.Form): __returns__ = ('password_strength',) password_strength: int | None = None + edit_user: User # TODO: This form has been deprecated with OTP-based reset as that doesn't need # username and now uses :class:`PasswordCreateForm`. This form is retained in the @@ -502,7 +503,7 @@ class EnableNotificationsDescriptionProtoMixin: enable_notifications: forms.Field - def set_queries(self) -> None: + def __post_init__(self) -> None: """Change the description to include a link.""" self.enable_notifications.description = Markup( _( @@ -614,7 +615,7 @@ class ModeratorReportForm(forms.Form): __("Report type"), coerce=int, validators=[forms.validators.InputRequired()] ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.report_type.choices = [ (idx, report_type.title) diff --git a/funnel/forms/helpers.py b/funnel/forms/helpers.py index e40ea4883..d789aafb0 100644 --- a/funnel/forms/helpers.py +++ b/funnel/forms/helpers.py @@ -9,9 +9,9 @@ from flask import flash from baseframe import _, __, forms -from coaster.auth import current_auth from .. import app +from ..auth import current_auth from ..models import ( Account, AccountEmailClaim, @@ -40,7 +40,7 @@ class AccountSelectField(forms.AutocompleteField): """Render an autocomplete field for selecting an account.""" - data: Account | None # type: ignore[assignment] + data: Account | None # type: ignore[assignment] # FIXME widget = forms.Select2Widget() multiple = False widget_autocomplete = True @@ -231,9 +231,7 @@ def image_url_validator() -> forms.validators.ValidUrl: """Customise ValidUrl for hosted image URL validation.""" return forms.validators.ValidUrl( allowed_schemes=lambda: app.config.get('IMAGE_URL_SCHEMES', ('https',)), - allowed_domains=lambda: app.config.get( # type: ignore[arg-type, return-value] - 'IMAGE_URL_DOMAINS' - ), + allowed_domains=lambda: app.config.get('IMAGE_URL_DOMAINS'), message_schemes=__("A https:// URL is required"), message_domains=__("Images must be hosted at images.hasgeek.com"), ) diff --git a/funnel/forms/login.py b/funnel/forms/login.py index 9df9f7e73..acbd76e2b 100644 --- a/funnel/forms/login.py +++ b/funnel/forms/login.py @@ -297,8 +297,7 @@ def validate_otp(self, field: forms.Field) -> None: class EmailOtpForm(OtpForm): """Verify an OTP sent to email.""" - def set_queries(self) -> None: - super().set_queries() + def __post_init__(self) -> None: self.otp.description = _("One-time password sent to your email address") diff --git a/funnel/forms/notification.py b/funnel/forms/notification.py index f0609355d..3e34d6a39 100644 --- a/funnel/forms/notification.py +++ b/funnel/forms/notification.py @@ -143,7 +143,7 @@ class UnsubscribeForm(forms.Form): __("Unsubscribe token type"), validators=[forms.validators.DataRequired()] ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" # Populate choices with all notification types that the user has a preference # row for. @@ -188,7 +188,7 @@ def set_types(self, obj) -> None: # self.types.data will only contain the enabled preferences. Therefore, iterate # through all choices and toggle true or false based on whether it's in the # enabled list. This uses dict access instead of .get because the rows are known - # to exist (set_queries loaded from this source). + # to exist (`__post_init__` loaded from this source). for ntype, _title in self.types.choices: obj.notification_preferences[ntype].set_transport( self.transport, ntype in self.types.data @@ -205,7 +205,7 @@ class SetNotificationPreferenceForm(forms.Form): ) enabled = forms.BooleanField(__("Enable this transport")) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" # The main switch is special-cased with an empty string for notification type self.notification_type.choices = [('', __("Main switch"))] + [ diff --git a/funnel/forms/profile.py b/funnel/forms/profile.py index 046c656cd..dc0815141 100644 --- a/funnel/forms/profile.py +++ b/funnel/forms/profile.py @@ -58,7 +58,7 @@ class ProfileForm(OrganizationForm): filters=nullable_strip_filters, ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.logo_url.profile = self.account.name or self.account.buid @@ -89,7 +89,7 @@ class ProfileTransitionForm(forms.Form): __("Account visibility"), validators=[forms.validators.DataRequired()] ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.transition.choices = list( self.edit_obj.profile_state.transitions().items() @@ -117,7 +117,7 @@ class ProfileLogoForm(forms.Form): filters=nullable_strip_filters, ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.logo_url.widget_type = 'modal' self.logo_url.profile = self.account.name or self.account.buid @@ -144,7 +144,7 @@ class ProfileBannerForm(forms.Form): filters=nullable_strip_filters, ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.banner_image_url.widget_type = 'modal' self.banner_image_url.profile = self.account.name or self.account.buid diff --git a/funnel/forms/project.py b/funnel/forms/project.py index 01879d6ac..51af407be 100644 --- a/funnel/forms/project.py +++ b/funnel/forms/project.py @@ -126,7 +126,7 @@ def validate_location(self, field: forms.Field) -> None: __("Quotes are not necessary in the location name") ) - def set_queries(self) -> None: + def __post_init__(self) -> None: self.bg_image.profile = self.account.name or self.account.buid if self.edit_obj is not None and self.edit_obj.schedule_start_at: # Don't allow user to directly manipulate timestamps when it's done via @@ -186,7 +186,7 @@ class ProjectNameForm(forms.Form): """Form to change the URL name of a project.""" # TODO: Add validators for `account` and unique name here instead of delegating to - # the view. Also add `set_queries` method to change ``name.prefix`` + # the view. Also add `__post_init__` method to change ``name.prefix`` name = forms.AnnotatedTextField( __("Custom URL"), @@ -236,7 +236,7 @@ class ProjectBannerForm(forms.Form): filters=nullable_strip_filters, ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.bg_image.widget_type = 'modal' self.bg_image.profile = self.account.name or self.account.buid @@ -280,7 +280,7 @@ class ProjectTransitionForm(forms.Form): __("Status"), validators=[forms.validators.DataRequired()] ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.transition.choices = list(self.edit_obj.state.transitions().items()) @@ -349,7 +349,7 @@ class RsvpTransitionForm(forms.Form): __("Status"), validators=[forms.validators.DataRequired()] ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" # Usually you need to use an instance's state.transitions to find # all the valid transitions for the current state of the instance. diff --git a/funnel/forms/proposal.py b/funnel/forms/proposal.py index a7e677ff5..deb243220 100644 --- a/funnel/forms/proposal.py +++ b/funnel/forms/proposal.py @@ -125,7 +125,7 @@ class ProposalLabelsForm(forms.Form): edit_parent: Project formlabels = forms.FormField(forms.Form, __("Labels")) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.formlabels.form = proposal_label_form( project=self.edit_parent, proposal=self.edit_obj @@ -139,7 +139,7 @@ class ProposalLabelsAdminForm(forms.Form): edit_parent: Project formlabels = forms.FormField(forms.Form, __("Labels")) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.formlabels.form = proposal_label_admin_form( project=self.edit_parent, proposal=self.edit_obj @@ -175,7 +175,7 @@ class ProposalForm(forms.Form): ) formlabels = forms.FormField(forms.Form, __("Labels")) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" label_form = proposal_label_form( project=self.edit_parent, proposal=self.edit_obj @@ -229,7 +229,7 @@ class ProposalTransitionForm(forms.Form): __("Status"), validators=[forms.validators.DataRequired()] ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" # value: transition method name # label: transition object itself @@ -251,6 +251,6 @@ class ProposalMoveForm(forms.Form): get_label='title', ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.target.query = self.user.projects_as_editor diff --git a/funnel/forms/sync_ticket.py b/funnel/forms/sync_ticket.py index 4a7445ffa..b00a88ba8 100644 --- a/funnel/forms/sync_ticket.py +++ b/funnel/forms/sync_ticket.py @@ -9,10 +9,10 @@ from baseframe import __, forms from ..models import ( - PROJECT_RSVP_STATE, Account, AccountEmail, Project, + ProjectRsvpStateEnum, TicketClient, TicketEvent, TicketParticipant, @@ -71,8 +71,8 @@ class ProjectBoxofficeForm(forms.Form): ) rsvp_state = forms.RadioField( __("Registrations"), - choices=PROJECT_RSVP_STATE.items(), - default=PROJECT_RSVP_STATE.NONE, + choices=[(int(member.value), member.title) for member in ProjectRsvpStateEnum], + default=int(ProjectRsvpStateEnum.NONE), ) is_subscription = forms.BooleanField( __("Paid tickets are for a subscription"), @@ -94,7 +94,7 @@ class ProjectBoxofficeForm(forms.Form): validators=[forms.validators.Optional(), validate_and_convert_json], ) - def set_queries(self): + def __post_init__(self): """Set form schema description.""" self.register_form_schema.description = Markup( '

{description}

{schema}
' @@ -214,7 +214,7 @@ class TicketParticipantForm(forms.Form): validators=[forms.validators.DataRequired("Select at least one event")], ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.ticket_events.query = self.edit_parent.ticket_events diff --git a/funnel/forms/venue.py b/funnel/forms/venue.py index b43cc44ff..8df27ce76 100644 --- a/funnel/forms/venue.py +++ b/funnel/forms/venue.py @@ -68,7 +68,7 @@ class VenueForm(forms.Form): validators=[forms.validators.Optional(), forms.validators.ValidCoordinates()], ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" pycountry_locale = gettext.translation( 'iso3166-2', pycountry.LOCALES_DIR, languages=[str(get_locale()), 'en'] @@ -124,6 +124,6 @@ class VenuePrimaryForm(forms.Form): render_kw={'autocorrect': 'off', 'autocapitalize': 'off'}, ) - def set_queries(self) -> None: + def __post_init__(self) -> None: """Prepare form for use.""" self.venue.query = self.edit_parent.venues diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index 7392d5834..127f39e5a 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -10,6 +10,7 @@ import sqlalchemy.exc as sa_exc import sqlalchemy.orm as sa_orm from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import Table from sqlalchemy.dialects import postgresql from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, declared_attr @@ -42,14 +43,14 @@ class Model(ModelBase, DeclarativeBase): """Base for all models.""" - __table__: ClassVar[sa.Table] + __table__: ClassVar[Table] __with_timezone__ = True class GeonameModel(ModelBase, DeclarativeBase): """Base for geoname models.""" - __table__: ClassVar[sa.Table] + __table__: ClassVar[Table] __bind_key__ = 'geoname' __with_timezone__ = True @@ -57,7 +58,10 @@ class GeonameModel(ModelBase, DeclarativeBase): # This must be set _before_ any of the models using db.Model are imported TimestampMixin.__with_timezone__ = True -db: SQLAlchemy = SQLAlchemy(query_class=Query, metadata=Model.metadata) # type: ignore[arg-type] +db: SQLAlchemy = SQLAlchemy( + query_class=Query, # type: ignore[arg-type] + metadata=Model.metadata, +) Model.init_flask_sqlalchemy(db) GeonameModel.init_flask_sqlalchemy(db) diff --git a/funnel/models/account.py b/funnel/models/account.py index 5cf9f93f1..927d2c2e5 100644 --- a/funnel/models/account.py +++ b/funnel/models/account.py @@ -8,7 +8,7 @@ import itertools from collections.abc import Iterable, Iterator, Sequence from datetime import datetime -from typing import TYPE_CHECKING, ClassVar, Literal, Self, cast, overload +from typing import TYPE_CHECKING, ClassVar, Literal, Self, TypeAlias, cast, overload from uuid import UUID import phonenumbers @@ -158,7 +158,7 @@ def __eq__(self, other: object) -> sa.ColumnElement[bool]: # type: ignore[overr # --- Models --------------------------------------------------------------------------- -class Account(UuidMixin, BaseMixin, Model): +class Account(UuidMixin, BaseMixin[int, 'Account'], Model): """Account model.""" __tablename__ = 'account' @@ -203,10 +203,10 @@ class Account(UuidMixin, BaseMixin, Model): #: 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') + username: Mapped[str | None] = 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() #: 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 @@ -221,22 +221,18 @@ class Account(UuidMixin, BaseMixin, Model): read={'owner'}, ) #: Update timezone automatically from browser activity - auto_timezone: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, default=True, nullable=False - ) + auto_timezone: Mapped[bool] = sa_orm.mapped_column(default=True) #: 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 - ) + auto_locale: Mapped[bool] = sa_orm.mapped_column(default=True) #: User's state code (active, suspended, merged, deleted) _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.SmallInteger, - StateManager.check_constraint('state', ACCOUNT_STATE), + StateManager.check_constraint('state', ACCOUNT_STATE, sa.SmallInteger), nullable=False, default=ACCOUNT_STATE.ACTIVE, ) @@ -250,7 +246,7 @@ class Account(UuidMixin, BaseMixin, Model): _profile_state: Mapped[int] = sa_orm.mapped_column( 'profile_state', sa.SmallInteger, - StateManager.check_constraint('profile_state', PROFILE_STATE), + StateManager.check_constraint('profile_state', PROFILE_STATE, sa.SmallInteger), nullable=False, default=PROFILE_STATE.AUTO, ) @@ -259,7 +255,7 @@ class Account(UuidMixin, BaseMixin, Model): ) tagline: Mapped[str | None] = sa_orm.mapped_column( - sa.Unicode, sa.CheckConstraint("tagline <> ''"), nullable=True + sa.CheckConstraint("tagline <> ''") ) description, description_text, description_html = MarkdownCompositeDocument.create( 'description', default='', nullable=False @@ -279,20 +275,18 @@ 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(default=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(default=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'} - ) + revisionid: Mapped[int] = with_roles(sa_orm.mapped_column(), read={'all'}) search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( @@ -986,6 +980,7 @@ def ticket_followers(self) -> Query[Account]: 'is_verified', }, } + __json_datasets__ = ('primary', 'related') profile_state.add_conditional_state( 'ACTIVE_AND_PUBLIC', @@ -1080,9 +1075,7 @@ 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 + sa.cast( # type: ignore[assignment] - '1 year', sa.Interval - ) + self.pw_expires_at = sa.func.utcnow() + sa.cast('1 year', sa.Interval) #: Write-only property (passwords cannot be read back in plain text) password = property(fset=_set_password, doc=_set_password.__doc__) @@ -1119,14 +1112,11 @@ def add_email( ) -> 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, - ), + accountemail = failsafe_add( + db.session, + accountemail, + account=self, + email_address=accountemail.email_address, ) if primary: self.primary_email = accountemail @@ -1176,14 +1166,11 @@ def add_phone( ) -> 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, - ), + accountphone = failsafe_add( + db.session, + accountphone, + account=self, + phone_number=accountphone.phone_number, ) if primary: self.primary_phone = accountphone @@ -1427,18 +1414,24 @@ def do_delete(self): raise ValueError("Account cannot be deleted") # 1. Delete contact information - for contact_source in ( - self.emails, - self.emailclaims, - self.phones, - self.externalids, + for contact_source in cast( + list, + ( + self.emails, + self.emailclaims, + self.phones, + self.externalids, + ), ): for contact in contact_source: db.session.delete(contact) # 2. Revoke all active memberships for membership in self.active_memberships(): - membership = membership.freeze_member_attribution(self) + if callable( + freeze := getattr(membership, 'freeze_member_attribution', None) + ): + membership = freeze(self) if membership.revoke_on_member_delete: membership.revoke(actor=self) # TODO: freeze fullname in unrevoked memberships (pending title column there) @@ -1508,7 +1501,7 @@ def uuid_zbase32(self) -> str: @classmethod def _uuid_zbase32_comparator(cls) -> ZBase32Comparator: """Return SQL comparator for :prop:`uuid_zbase32`.""" - return ZBase32Comparator(cls.uuid) + return ZBase32Comparator(cls.uuid) # type: ignore[arg-type] @classmethod def name_is(cls, name: str) -> ColumnElement: @@ -1889,7 +1882,7 @@ def membership_project(self) -> Project | None: add_search_trigger(Account, 'name_vector') -class AccountOldId(UuidMixin, BaseMixin[UUID], Model): +class AccountOldId(UuidMixin, BaseMixin[UUID, Account], Model): """Record of an older UUID for an account, after account merger.""" __tablename__ = 'account_oldid' @@ -1902,7 +1895,7 @@ class AccountOldId(UuidMixin, BaseMixin[UUID], Model): ) #: User id of new user account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None, nullable=False ) #: New account account: Mapped[Account] = relationship( @@ -2047,7 +2040,7 @@ class Placeholder(Account): is_placeholder_profile = True -class Team(UuidMixin, BaseMixin, Model): +class Team(UuidMixin, BaseMixin[int, Account], Model): """A team of users within an organization.""" __tablename__ = 'team' @@ -2058,7 +2051,7 @@ class Team(UuidMixin, BaseMixin, Model): ) #: Organization account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False, index=True + sa.ForeignKey('account.id'), default=None, nullable=False, index=True ) account: Mapped[Account] = with_roles( relationship(foreign_keys=[account_id], back_populates='teams'), @@ -2071,9 +2064,7 @@ class Team(UuidMixin, BaseMixin, Model): grants={'member'}, ) - is_public: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + is_public: Mapped[bool] = sa_orm.mapped_column(default=False) # --- Backrefs client_permissions: Mapped[list[AuthClientTeamPermissions]] = relationship( @@ -2103,7 +2094,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 [cast(sa.Table, cls.__table__).name, team_membership.name] + return [cls.__table__.name, team_membership.name] @classmethod def get(cls, buid: str, with_parent: bool = False) -> Team | None: @@ -2122,7 +2113,7 @@ def get(cls, buid: str, with_parent: bool = False) -> Team | None: # --- Account email/phone and misc -class AccountEmail(EmailAddressMixin, BaseMixin, Model): +class AccountEmail(EmailAddressMixin, BaseMixin[int, Account], Model): """An email address linked to an account.""" __tablename__ = 'account_email' @@ -2130,18 +2121,13 @@ class AccountEmail(EmailAddressMixin, BaseMixin, Model): __email_is_exclusive__ = True __email_for__ = 'account' - # Tell mypy that these are not optional - email_address: Mapped[EmailAddress] # type: ignore[assignment] - account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None, nullable=False ) account: Mapped[Account] = relationship(back_populates='emails') user: Mapped[Account] = sa_orm.synonym('account') - private: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + private: Mapped[bool] = sa_orm.mapped_column(default=False) __datasets__ = { 'primary': {'member', 'email', 'private', 'type'}, @@ -2297,10 +2283,10 @@ def migrate_account( if new_account.primary_email is None: new_account.primary_email = primary_email old_account.primary_email = None - return [cast(sa.Table, cls.__table__).name, user_email_primary_table.name] + return [cls.__table__.name, user_email_primary_table.name] -class AccountEmailClaim(EmailAddressMixin, BaseMixin, Model): +class AccountEmailClaim(EmailAddressMixin, BaseMixin[int, Account], Model): """Claimed but unverified email address for a user.""" __tablename__ = 'account_email_claim' @@ -2308,21 +2294,16 @@ class AccountEmailClaim(EmailAddressMixin, BaseMixin, Model): __email_for__ = 'account' __email_is_exclusive__ = False - # Tell mypy that these are not optional - email_address: Mapped[EmailAddress] # type: ignore[assignment] - account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None, nullable=False ) 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 + sa.String(44), nullable=False, insert_default=newsecret, default=None ) - private: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + private: Mapped[bool] = sa_orm.mapped_column(default=False) __table_args__ = (sa.UniqueConstraint('account_id', 'email_address_id'),) @@ -2489,7 +2470,7 @@ def all(cls, email: str) -> Query[Self]: # noqa: A003 auto_init_default(AccountEmailClaim.verification_code) -class AccountPhone(PhoneNumberMixin, BaseMixin, Model): +class AccountPhone(PhoneNumberMixin, BaseMixin[int, Account], Model): """A phone number linked to an account.""" __tablename__ = 'account_phone' @@ -2498,14 +2479,12 @@ class AccountPhone(PhoneNumberMixin, BaseMixin, Model): __phone_for__ = 'account' account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None ) account: Mapped[Account] = relationship(back_populates='phones') user: Mapped[Account] = sa_orm.synonym('account') - private: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + private: Mapped[bool] = sa_orm.mapped_column(default=False) __datasets__ = { 'primary': {'member', 'phone', 'private', 'type'}, @@ -2674,17 +2653,17 @@ def migrate_account( if new_account.primary_phone is None: new_account.primary_phone = primary_phone old_account.primary_phone = None - return [cast(sa.Table, cls.__table__).name, user_phone_primary_table.name] + return [cls.__table__.name, user_phone_primary_table.name] -class AccountExternalId(BaseMixin, Model): +class AccountExternalId(BaseMixin[int, Account], Model): """An external connected account for a user.""" __tablename__ = 'account_externalid' __at_username_services__: ClassVar[list[str]] = [] #: Foreign key to user table account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None ) #: User that this connected account belongs to account: Mapped[Account] = relationship(back_populates='externalids') @@ -2698,10 +2677,8 @@ class AccountExternalId(BaseMixin, Model): sa.UnicodeText, nullable=False ) # Unique id (or obsolete OpenID) #: Optional public-facing username on the external service - # FIXME: change to sa.Unicode - username: Mapped[str | None] = sa_orm.mapped_column( - sa.UnicodeText, nullable=True - ) # LinkedIn once used full URLs + # FIXME: change to sa.Unicode. LinkedIn once used full URLs + username: Mapped[str | None] = sa_orm.mapped_column(sa.UnicodeText, nullable=True) #: OAuth or OAuth2 access token # FIXME: change to sa.Unicode oauth_token: Mapped[str | None] = sa_orm.mapped_column( @@ -2723,9 +2700,7 @@ class AccountExternalId(BaseMixin, Model): sa.UnicodeText, nullable=True ) #: OAuth2 token expiry in seconds, as sent by service provider - oauth_expires_in: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, nullable=True - ) + oauth_expires_in: Mapped[int | None] = sa_orm.mapped_column() #: OAuth2 token expiry timestamp, estimate from created_at + oauth_expires_in oauth_expires_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True @@ -2733,7 +2708,7 @@ class AccountExternalId(BaseMixin, Model): #: Timestamp of when this connected account was last (re-)authorised by the user last_used_at: Mapped[datetime] = sa_orm.mapped_column( - sa.TIMESTAMP(timezone=True), default=sa.func.utcnow(), nullable=False + sa.TIMESTAMP(timezone=True), insert_default=sa.func.utcnow(), default=None ) __table_args__ = ( @@ -2801,7 +2776,9 @@ def get( ) #: Anchor type -Anchor = AccountEmail | AccountEmailClaim | AccountPhone | EmailAddress | PhoneNumber +Anchor: TypeAlias = ( + AccountEmail | AccountEmailClaim | AccountPhone | EmailAddress | PhoneNumber +) # Tail imports from .account_membership import AccountMembership diff --git a/funnel/models/account_membership.py b/funnel/models/account_membership.py index ec16f669b..d9f91aeda 100644 --- a/funnel/models/account_membership.py +++ b/funnel/models/account_membership.py @@ -8,12 +8,12 @@ from . import Mapped, Model, relationship, sa, sa_orm from .account import Account -from .membership_mixin import ImmutableUserMembershipMixin +from .membership_mixin import ImmutableMembershipMixin __all__ = ['AccountMembership'] -class AccountMembership(ImmutableUserMembershipMixin, Model): +class AccountMembership(ImmutableMembershipMixin, Model): """ An account can be a member of another account as an owner, admin or follower. @@ -50,7 +50,7 @@ class AccountMembership(ImmutableUserMembershipMixin, Model): 'account_admin': { 'read': { 'record_type', - 'record_type_label', + 'record_type_enum', 'granted_at', 'granted_by', 'revoked_at', @@ -78,8 +78,8 @@ class AccountMembership(ImmutableUserMembershipMixin, Model): #: Organization that this membership is being granted on account_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('account.id', ondelete='CASCADE'), + default=None, nullable=False, ) account: Mapped[Account] = with_roles( @@ -91,9 +91,7 @@ class AccountMembership(ImmutableUserMembershipMixin, Model): parent: Mapped[Account] = sa_orm.synonym('account') # Organization roles: - is_owner: Mapped[bool] = immutable( - sa_orm.mapped_column(sa.Boolean, nullable=False, default=False) - ) + is_owner: Mapped[bool] = immutable(sa_orm.mapped_column(default=False)) @cached_property def offered_roles(self) -> set[str]: diff --git a/funnel/models/auth_client.py b/funnel/models/auth_client.py index 1b07404da..82bb7bf90 100644 --- a/funnel/models/auth_client.py +++ b/funnel/models/auth_client.py @@ -84,14 +84,14 @@ def add_scope(self, additional: str | Collection) -> None: self.scope = set(self.scope).union(set(additional)) -class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): +class AuthClient(ScopeMixin, UuidMixin, BaseMixin[int, Account], Model): """OAuth client application.""" __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=False + sa.ForeignKey('account.id'), default=None, nullable=False ) account: Mapped[Account] = with_roles( relationship(back_populates='clients'), @@ -113,7 +113,7 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): ) #: 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(), read={'all'}, write={'owner'} ) #: Website website: Mapped[str] = with_roles( @@ -130,12 +130,10 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): sa_orm.mapped_column(sa.UnicodeText, nullable=True, default=''), rw={'owner'} ) #: Active flag - active: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=True - ) + active: Mapped[bool] = sa_orm.mapped_column(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(default=True), read={'all'}, write={'owner'}, ) @@ -146,7 +144,7 @@ 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(default=False), read={'all'} ) # --- Backrefs @@ -203,8 +201,7 @@ def redirect_uris(self, value: Sequence[str]) -> None: @property def redirect_uri(self) -> str | None: """Return the first redirect URI, if present.""" - uris = self.redirect_uris # Assign to local var to avoid splitting twice - if uris: + if uris := self.redirect_uris: return uris[0] return None @@ -275,7 +272,7 @@ def all_for(cls, account: Account | None) -> Query[Self]: ).order_by(cls.title) -class AuthClientCredential(BaseMixin, Model): +class AuthClientCredential(BaseMixin[int, Account], Model): """ AuthClient key and secret hash. @@ -296,7 +293,7 @@ class AuthClientCredential(BaseMixin, Model): __tablename__ = 'auth_client_credential' auth_client_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False + sa.ForeignKey('auth_client.id'), default=None, nullable=False ) auth_client: Mapped[AuthClient] = with_roles( relationship(back_populates='credentials'), @@ -305,14 +302,18 @@ class AuthClientCredential(BaseMixin, Model): #: OAuth client key name: Mapped[str] = sa_orm.mapped_column( - sa.String(22), nullable=False, unique=True, default=make_buid + sa.String(22), + insert_default=make_buid, + default=None, + nullable=False, + unique=True, ) #: User description for this credential 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() #: When was this credential last used for an API call? accessed_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True @@ -371,27 +372,27 @@ def new(cls, auth_client: AuthClient) -> tuple[AuthClientCredential, str]: return cred, secret -class AuthCode(ScopeMixin, BaseMixin, Model): +class AuthCode(ScopeMixin, BaseMixin[int, Account], Model): """Short-lived authorization tokens.""" __tablename__ = 'auth_code' account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None, nullable=False ) 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 + sa.ForeignKey('auth_client.id'), default=None, nullable=False ) 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 + sa.ForeignKey('login_session.id'), default=None, nullable=True ) login_session: Mapped[LoginSession | None] = relationship() code: Mapped[str] = sa_orm.mapped_column( - sa.String(44), default=newsecret, nullable=False + sa.String(44), insert_default=newsecret, default=None, 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) + used: Mapped[bool] = sa_orm.mapped_column(default=False) def is_valid(self) -> bool: """Test if this auth code is still valid.""" @@ -412,19 +413,19 @@ def get_for_client(cls, auth_client: AuthClient, code: str) -> AuthCode | None: ).one_or_none() -class AuthToken(ScopeMixin, BaseMixin, Model): +class AuthToken(ScopeMixin, BaseMixin[int, Account], Model): """Access tokens for access to data.""" __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( - sa.ForeignKey('account.id'), nullable=True + sa.ForeignKey('account.id'), default=None, nullable=True ) 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( - sa.Integer, sa.ForeignKey('login_session.id'), nullable=True + sa.ForeignKey('login_session.id'), default=None, nullable=True ) login_session: Mapped[LoginSession | None] = with_roles( relationship(back_populates='authtokens'), @@ -432,7 +433,7 @@ class AuthToken(ScopeMixin, BaseMixin, Model): ) #: 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 + sa.ForeignKey('auth_client.id'), default=None, nullable=False, index=True ) auth_client: Mapped[AuthClient] = with_roles( relationship(back_populates='authtokens'), @@ -440,7 +441,11 @@ class AuthToken(ScopeMixin, BaseMixin, Model): ) #: The token token: Mapped[str] = sa_orm.mapped_column( - sa.String(22), default=make_buid, nullable=False, unique=True + sa.String(22), + insert_default=make_buid, + default=None, + nullable=False, + unique=True, ) #: The token's type, 'bearer', 'mac' or a URL token_type: Mapped[str] = sa_orm.mapped_column( @@ -633,19 +638,19 @@ def all_for(cls, account: Account) -> Query[Self]: # This model's name is in plural because it defines multiple permissions within each # instance -class AuthClientPermissions(BaseMixin, Model): +class AuthClientPermissions(BaseMixin[int, Account], Model): """Permissions assigned to an account on a client app.""" __tablename__ = 'auth_client_permissions' __tablename__ = 'auth_client_permissions' #: User account that has these permissions account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None, nullable=False ) account: Mapped[Account] = relationship(back_populates='client_permissions') #: AuthClient app they are assigned on auth_client_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True + sa.ForeignKey('auth_client.id'), default=None, nullable=False, index=True ) auth_client: Mapped[AuthClient] = with_roles( relationship(back_populates='account_permissions'), @@ -710,18 +715,18 @@ def all_forclient(cls, auth_client: AuthClient) -> Query[Self]: # This model's name is in plural because it defines multiple permissions within each # instance -class AuthClientTeamPermissions(BaseMixin, Model): +class AuthClientTeamPermissions(BaseMixin[int, Account], Model): """Permissions assigned to a team on a client app.""" __tablename__ = 'auth_client_team_permissions' #: Team which has these permissions team_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('team.id'), nullable=False + sa.ForeignKey('team.id'), default=None, nullable=False ) team: Mapped[Team] = relationship(back_populates='client_permissions') #: AuthClient app they are assigned on auth_client_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True + sa.ForeignKey('auth_client.id'), default=None, nullable=False, index=True ) auth_client: Mapped[AuthClient] = with_roles( relationship(back_populates='team_permissions'), diff --git a/funnel/models/comment.py b/funnel/models/comment.py index 389b80df2..25ec784d3 100644 --- a/funnel/models/comment.py +++ b/funnel/models/comment.py @@ -81,13 +81,13 @@ class SET_TYPE: # noqa: N801 # --- Models --------------------------------------------------------------------------- -class Commentset(UuidMixin, BaseMixin, Model): +class Commentset(UuidMixin, BaseMixin[int, Account], Model): __tablename__ = 'commentset' #: Commentset state code _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.SmallInteger, - StateManager.check_constraint('state', COMMENTSET_STATE), + StateManager.check_constraint('state', COMMENTSET_STATE, sa.SmallInteger), nullable=False, default=COMMENTSET_STATE.OPEN, ) @@ -97,13 +97,13 @@ class Commentset(UuidMixin, BaseMixin, Model): ) #: Type of parent object settype: Mapped[int | None] = with_roles( - sa_orm.mapped_column('type', sa.Integer, nullable=True), + sa_orm.mapped_column('type', 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(default=0, nullable=False), read={'all'}, datasets={'primary'}, ) @@ -296,18 +296,18 @@ def remove_subscriber(self, actor: Account, member: Account) -> bool: return False -class Comment(UuidMixin, BaseMixin, Model): +class Comment(UuidMixin, BaseMixin[int, Account], Model): __tablename__ = 'comment' posted_by_id: Mapped[int | None] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=True + sa.ForeignKey('account.id'), default=None, nullable=True ) _posted_by: Mapped[Account | None] = with_roles( relationship(back_populates='comments'), grants={'author'}, ) commentset_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('commentset.id'), nullable=False + sa.ForeignKey('commentset.id'), default=None, nullable=False ) commentset: Mapped[Commentset] = with_roles( relationship(back_populates='comments'), @@ -315,7 +315,7 @@ class Comment(UuidMixin, BaseMixin, Model): ) in_reply_to_id: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('comment.id'), nullable=True + sa.ForeignKey('comment.id'), default=None, nullable=True ) in_reply_to: Mapped[Comment] = relationship( back_populates='replies', remote_side='Comment.id' @@ -328,8 +328,7 @@ class Comment(UuidMixin, BaseMixin, Model): _state: Mapped[int] = sa_orm.mapped_column( 'state', - sa.Integer, - StateManager.check_constraint('state', COMMENT_STATE), + StateManager.check_constraint('state', COMMENT_STATE, sa.Integer), default=COMMENT_STATE.SUBMITTED, nullable=False, ) @@ -344,9 +343,7 @@ class Comment(UuidMixin, BaseMixin, Model): ) #: Revision number maintained by SQLAlchemy, starting at 1 - revisionid: Mapped[int] = with_roles( - sa_orm.mapped_column(sa.Integer, nullable=False), read={'all'} - ) + revisionid: Mapped[int] = with_roles(sa_orm.mapped_column(), read={'all'}) search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( @@ -383,6 +380,7 @@ class Comment(UuidMixin, BaseMixin, Model): 'json': {'created_at', 'urls', 'uuid_b58', 'absolute_url'}, 'minimal': {'created_at', 'uuid_b58'}, } + __json_datasets__ = ('json',) def __init__(self, **kwargs) -> None: super().__init__(**kwargs) @@ -418,7 +416,7 @@ def posted_by(self) -> Account | DuckTypeAccount: def _posted_by_setter(self, value: Account | None) -> None: self._posted_by = value - @posted_by.inplace.expression + @posted_by.inplace.expression # type: ignore[arg-type] @classmethod def _posted_by_expression(cls) -> sa_orm.InstrumentedAttribute[Account | None]: """Return SQL Expression.""" diff --git a/funnel/models/commentset_membership.py b/funnel/models/commentset_membership.py index 409332f6d..5565fc1ac 100644 --- a/funnel/models/commentset_membership.py +++ b/funnel/models/commentset_membership.py @@ -9,15 +9,12 @@ from . import Mapped, Model, Query, relationship, sa, sa_orm from .account import Account -from .membership_mixin import ImmutableUserMembershipMixin -from .project import Project -from .proposal import Proposal -from .update import Update +from .membership_mixin import ImmutableMembershipMixin __all__ = ['CommentsetMembership'] -class CommentsetMembership(ImmutableUserMembershipMixin, Model): +class CommentsetMembership(ImmutableMembershipMixin, Model): """Membership roles for users who are commentset users and subscribers.""" __tablename__ = 'commentset_membership' @@ -38,9 +35,7 @@ class CommentsetMembership(ImmutableUserMembershipMixin, Model): } commentset_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, - sa.ForeignKey('commentset.id', ondelete='CASCADE'), - nullable=False, + sa.ForeignKey('commentset.id', ondelete='CASCADE'), default=None, nullable=False ) commentset: Mapped[Commentset] = relationship() @@ -49,9 +44,7 @@ class CommentsetMembership(ImmutableUserMembershipMixin, Model): parent: Mapped[Commentset] = sa_orm.synonym('commentset') #: Flag to indicate notifications are muted - is_muted: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + is_muted: Mapped[bool] = sa_orm.mapped_column(default=False) #: When the user visited this commentset last last_seen_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() @@ -104,11 +97,14 @@ def for_user(cls, account: Account) -> Query[Self]: # Tail imports from .comment import Comment, Commentset +from .project import Project +from .proposal import Proposal +from .update import Update 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.state.PUBLIC) # type: ignore[has-type] # FIXME .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 d9398bc4a..d6f17de2d 100644 --- a/funnel/models/contact_exchange.py +++ b/funnel/models/contact_exchange.py @@ -9,7 +9,7 @@ from typing import Self from uuid import UUID -from pytz import timezone +from pytz import BaseTzInfo, timezone from sqlalchemy.ext.associationproxy import association_proxy from coaster.sqlalchemy import LazyRoleSet @@ -42,7 +42,7 @@ class ProjectId: uuid: UUID uuid_b58: str title: str - timezone: str + timezone: BaseTzInfo @dataclass @@ -51,7 +51,7 @@ class DateCountContacts: date: datetime count: int - contacts: Collection[ContactExchange] + contacts: Collection[ContactExchange] | Query[ContactExchange] class ContactExchange(TimestampMixin, RoleMixin, Model): @@ -60,14 +60,14 @@ class ContactExchange(TimestampMixin, RoleMixin, Model): __tablename__ = 'contact_exchange' #: User who scanned this contact account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id', ondelete='CASCADE'), primary_key=True + sa.ForeignKey('account.id', ondelete='CASCADE'), primary_key=True, default=None ) account: Mapped[Account] = relationship(back_populates='scanned_contacts') #: Participant whose contact was scanned ticket_participant_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('ticket_participant.id', ondelete='CASCADE'), primary_key=True, + default=None, index=True, ) ticket_participant: Mapped[TicketParticipant] = relationship( @@ -75,16 +75,17 @@ class ContactExchange(TimestampMixin, RoleMixin, Model): ) #: Datetime at which the scan happened scanned_at: Mapped[datetime] = sa_orm.mapped_column( - sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() + sa.TIMESTAMP(timezone=True), + nullable=False, + insert_default=sa.func.utcnow(), + default=None, ) #: Note recorded by the user (plain text) description: Mapped[str] = sa_orm.mapped_column( sa.UnicodeText, nullable=False, default='' ) #: Archived flag - archived: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + archived: Mapped[bool] = sa_orm.mapped_column(default=False) __roles__ = { 'owner': { @@ -133,8 +134,8 @@ def grouped_counts_for( cls.scanned_at.label('scanned_at'), Project.id.label('project_id'), Project.uuid.label('project_uuid'), - Project.timezone.label('project_timezone'), Project.title.label('project_title'), + Project.timezone.label('project_timezone'), ).filter( cls.ticket_participant_id == TicketParticipant.id, TicketParticipant.project_id == Project.id, @@ -224,7 +225,7 @@ def grouped_counts_for( [ DateCountContacts( r.scan_date, - r.count, + r.count, # type: ignore[arg-type] # FIXME cls.contacts_for_project_and_date( account, k, r.scan_date, archived ), @@ -234,12 +235,12 @@ def grouped_counts_for( ) for k, g in groupby( query, - lambda r: ProjectId( - id=r.project_id, - uuid=r.project_uuid, - uuid_b58=uuid_to_base58(r.project_uuid), - title=r.project_title, - timezone=timezone(r.project_timezone), + lambda row: ProjectId( + id=row.project_id, + uuid=row.project_uuid, + uuid_b58=uuid_to_base58(row.project_uuid), + title=row.project_title, + timezone=timezone(row.project_timezone), ), ) ] @@ -248,7 +249,11 @@ def grouped_counts_for( @classmethod def contacts_for_project_and_date( - cls, account: Account, project: Project, date: date_type, archived: bool = False + cls, + account: Account, + project: Project | ProjectId, + date: date_type, + archived: bool = False, ) -> Query[Self]: """Return contacts for a given user, project and date.""" query = cls.query.join(TicketParticipant).filter( diff --git a/funnel/models/email_address.py b/funnel/models/email_address.py index 773b9f99c..2a14e0d2e 100644 --- a/funnel/models/email_address.py +++ b/funnel/models/email_address.py @@ -157,7 +157,7 @@ class EmailAddressInUseError(EmailAddressError): """Email address is in use by another owner.""" -class EmailAddress(BaseMixin, Model): +class EmailAddress(BaseMixin[int, 'Account'], Model): """ Represents an email address as a standalone entity, with associated metadata. @@ -199,12 +199,10 @@ class methods, depending on whether the email address is linked to an owner or n #: 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() #: 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', sa.Unicode, nullable=True, index=True - ) + _domain: Mapped[str | None] = sa_orm.mapped_column('domain', index=True) # email_normalized is defined below @@ -233,10 +231,10 @@ class methods, depending on whether the email address is linked to an owner or n #: Does this email address work? Records last known delivery state _delivery_state: Mapped[int] = sa_orm.mapped_column( 'delivery_state', - sa.Integer, StateManager.check_constraint( 'delivery_state', EMAIL_DELIVERY_STATE, + sa.Integer, name='email_address_delivery_state_check', ), nullable=False, @@ -249,7 +247,10 @@ class methods, depending on whether the email address is linked to an owner or n ) #: Timestamp of last known delivery state delivery_state_at: Mapped[datetime] = sa_orm.mapped_column( - sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() + sa.TIMESTAMP(timezone=True), + nullable=False, + insert_default=sa.func.utcnow(), + default=None, ) #: Timestamp of last known recipient activity resulting from sent mail active_at: Mapped[datetime | None] = sa_orm.mapped_column( @@ -261,9 +262,7 @@ class methods, depending on whether the email address is linked to an owner or n #: 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', sa.Boolean, nullable=False, default=False - ) + _is_blocked: Mapped[bool] = sa_orm.mapped_column('is_blocked', default=False) __table_args__ = ( # `domain` must be lowercase always. Note that Python `.lower()` is not @@ -743,6 +742,7 @@ def email_address_id(cls) -> Mapped[int | None]: return sa_orm.mapped_column( sa.Integer, sa.ForeignKey('email_address.id', ondelete='SET NULL'), + default=None, nullable=cls.__email_optional__, unique=cls.__email_unique__, index=not cls.__email_unique__, diff --git a/funnel/models/geoname.py b/funnel/models/geoname.py index c8755361b..ace2cd2fb 100644 --- a/funnel/models/geoname.py +++ b/funnel/models/geoname.py @@ -113,7 +113,10 @@ class GeoAdmin1Code(BaseMixin, GeonameModel): 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', + sa.CHAR(2), + sa.ForeignKey('geo_country_info.iso_alpha2'), + default=None, ) country: Mapped[GeoCountryInfo | None] = relationship() admin1_code: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) @@ -138,7 +141,10 @@ class GeoAdmin2Code(BaseMixin, GeonameModel): 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', + sa.CHAR(2), + sa.ForeignKey('geo_country_info.iso_alpha2'), + default=None, ) country: Mapped[GeoCountryInfo | None] = relationship() admin1_code: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) @@ -161,7 +167,10 @@ class GeoName(BaseNameMixin, GeonameModel): 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', + sa.CHAR(2), + sa.ForeignKey('geo_country_info.iso_alpha2'), + default=None, ) country: Mapped[GeoCountryInfo | None] = relationship() cc2: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) @@ -175,7 +184,7 @@ class GeoName(BaseNameMixin, GeonameModel): viewonly=True, ) admin1_id: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('geo_admin1_code.id'), nullable=True + sa.ForeignKey('geo_admin1_code.id'), default=None, nullable=True ) admin1code: Mapped[GeoAdmin1Code | None] = relationship( uselist=False, foreign_keys=[admin1_id] @@ -192,7 +201,7 @@ class GeoName(BaseNameMixin, GeonameModel): viewonly=True, ) admin2_id: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('geo_admin2_code.id'), nullable=True + sa.ForeignKey('geo_admin2_code.id'), default=None, nullable=True ) admin2code: Mapped[GeoAdmin2Code | None] = relationship( uselist=False, foreign_keys=[admin2_id] @@ -201,9 +210,9 @@ class GeoName(BaseNameMixin, GeonameModel): 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) + elevation: Mapped[int | None] = sa_orm.mapped_column() #: Digital Elevation Model - dem: Mapped[int | None] = sa_orm.mapped_column(sa.Integer) + dem: Mapped[int | None] = sa_orm.mapped_column() timezone: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode) moddate: Mapped[date | None] = sa_orm.mapped_column(sa.Date) @@ -382,7 +391,7 @@ def related_geonames(self) -> dict[str, GeoName]: and self.country and self.country.continent ): - continent = GeoName.query.get(continent_codes[self.country.continent]) + continent = db.session.get(GeoName, continent_codes[self.country.continent]) if continent: related['continent'] = continent @@ -627,17 +636,15 @@ class GeoAltName(BaseMixin, GeonameModel): __tablename__ = 'geo_alt_name' geonameid: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('geo_name.id'), nullable=False + sa.ForeignKey('geo_name.id'), default=None, nullable=False ) 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[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) + lang: Mapped[str | None] = sa_orm.mapped_column(index=True) + title: Mapped[str] = sa_orm.mapped_column() + is_preferred_name: Mapped[bool] = sa_orm.mapped_column() + is_short_name: Mapped[bool] = sa_orm.mapped_column() + is_colloquial: Mapped[bool] = sa_orm.mapped_column() + is_historic: Mapped[bool] = sa_orm.mapped_column() __table_args__ = ( sa.Index( diff --git a/funnel/models/helpers.py b/funnel/models/helpers.py index 105fc0755..549f25a51 100644 --- a/funnel/models/helpers.py +++ b/funnel/models/helpers.py @@ -22,6 +22,8 @@ from sqlalchemy.orm import Mapped, composite from zxcvbn import zxcvbn +from coaster.utils import DataclassFromType + from .. import app from ..typing import T from ..utils import MarkdownConfig, MarkdownString, markdown_escape @@ -31,6 +33,7 @@ 'RESERVED_NAMES', 'PASSWORD_MIN_LENGTH', 'PASSWORD_MAX_LENGTH', + 'IntTitle', 'check_password_strength', 'profanity', 'add_to_class', @@ -132,6 +135,14 @@ } +@dataclass(frozen=True) +class IntTitle(DataclassFromType, int): + """Integer value with a title (for enums).""" + + # The empty default is required for Mypy's enum plugin's `Enum.__call__` analysis + title: str = '' + + @dataclass class PasswordCheckType: """ diff --git a/funnel/models/label.py b/funnel/models/label.py index 3f0a4c150..83ecae347 100644 --- a/funnel/models/label.py +++ b/funnel/models/label.py @@ -18,10 +18,11 @@ sa, sa_orm, ) +from .account import Account from .helpers import add_search_trigger, visual_field_delimiter from .project_membership import project_child_role_map -proposal_label = sa.Table( +proposal_label: sa.Table = sa.Table( 'proposal_label', Model.metadata, sa.Column( @@ -43,11 +44,11 @@ ) -class Label(BaseScopedNameMixin, Model): +class Label(BaseScopedNameMixin[int, Account], Model): __tablename__ = 'label' project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False + sa.ForeignKey('project.id', ondelete='CASCADE'), default=None, nullable=False ) # Backref from project is defined in the Project model with an ordering list project: Mapped[Project] = with_roles( @@ -61,8 +62,8 @@ class Label(BaseScopedNameMixin, Model): #: 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( - sa.Integer, sa.ForeignKey('label.id', ondelete='CASCADE'), + default=None, index=True, nullable=True, ) @@ -82,7 +83,7 @@ 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() # A single-line description of this label, shown when picking labels (optional) description: Mapped[str] = sa_orm.mapped_column( @@ -97,22 +98,16 @@ class Label(BaseScopedNameMixin, Model): #: 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', sa.Boolean, nullable=False, default=False - ) + _restricted: Mapped[bool] = sa_orm.mapped_column('restricted', 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', sa.Boolean, nullable=False, default=False - ) + _required: Mapped[bool] = sa_orm.mapped_column('required', 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', sa.Boolean, nullable=False, default=False - ) + _archived: Mapped[bool] = sa_orm.mapped_column('archived', default=False) search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( diff --git a/funnel/models/login_session.py b/funnel/models/login_session.py index f983a4912..422542c45 100644 --- a/funnel/models/login_session.py +++ b/funnel/models/login_session.py @@ -87,32 +87,28 @@ class LoginSessionInactiveUserError(LoginSessionError): ) -class LoginSession(UuidMixin, BaseMixin, Model): +class LoginSession(UuidMixin, BaseMixin[int, Account], Model): __tablename__ = 'login_session' account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None, nullable=False ) 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) #: 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() #: State/subdivision geonameid from IP address - geonameid_subdivision: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, nullable=True - ) + geonameid_subdivision: Mapped[int | None] = sa_orm.mapped_column() #: Country geonameid from IP address - geonameid_country: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, nullable=True - ) + geonameid_country: Mapped[int | None] = sa_orm.mapped_column() #: 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() #: User agent 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() accessed_at: Mapped[datetime] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=False @@ -121,7 +117,10 @@ class LoginSession(UuidMixin, BaseMixin, Model): sa.TIMESTAMP(timezone=True), nullable=True ) sudo_enabled_at: Mapped[datetime] = sa_orm.mapped_column( - sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() + sa.TIMESTAMP(timezone=True), + nullable=False, + insert_default=sa.func.utcnow(), + default=None, ) # --- Backrefs diff --git a/funnel/models/mailer.py b/funnel/models/mailer.py index 9dec9157a..c60e7f6d7 100644 --- a/funnel/models/mailer.py +++ b/funnel/models/mailer.py @@ -5,7 +5,7 @@ import re from collections.abc import Collection, Iterator from datetime import datetime -from enum import IntEnum +from enum import ReprEnum from typing import Any, Self from uuid import UUID @@ -31,6 +31,7 @@ sa_orm, ) from .account import Account +from .helpers import IntTitle from .types import jsonb __all__ = [ @@ -48,41 +49,31 @@ EMAIL_TAGS[_key].append('style') -class MailerState(IntEnum): +class MailerState(IntTitle, ReprEnum): """Send state for :class:`Mailer`.""" - DRAFT = 0 - QUEUED = 1 - SENDING = 2 - SENT = 3 + DRAFT = 0, __("Draft") + QUEUED = (1, __("Queued")) + SENDING = 2, __("Sending") + SENT = 3, __("Sent") - __titles__ = { - DRAFT: __("Draft"), - QUEUED: __("Queued"), - SENDING: __("Sending"), - SENT: __("Sent"), - } - def __init__(self, value: int) -> None: - self.title = self.__titles__[value] - - -class Mailer(BaseNameMixin, Model): +class Mailer(BaseNameMixin[int, Account], Model): """A mailer sent via email to multiple recipients.""" __tablename__ = 'mailer' - user_uuid: Mapped[UUID] = sa_orm.mapped_column(sa.ForeignKey('account.uuid')) + user_uuid: Mapped[UUID] = sa_orm.mapped_column( + sa.ForeignKey('account.uuid'), default=None + ) user: Mapped[Account] = relationship(back_populates='mailers') status: Mapped[int] = sa_orm.mapped_column( - sa.Integer, nullable=False, default=MailerState.DRAFT + nullable=False, default=MailerState.DRAFT ) _fields: Mapped[str] = sa_orm.mapped_column( 'fields', sa.UnicodeText, nullable=False, default='' ) - trackopens: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + trackopens: Mapped[bool] = sa_orm.mapped_column(default=False) stylesheet: Mapped[str] = sa_orm.mapped_column( sa.UnicodeText, nullable=False, default='' ) @@ -165,7 +156,7 @@ def recipients_iter(self) -> Iterator[MailerRecipient]: .all() ] for rid in ids: - recipient = MailerRecipient.query.get(rid) + recipient = db.session.get(MailerRecipient, rid) if recipient: yield recipient @@ -194,13 +185,13 @@ def render_preview(self, text: str) -> str: return '' -class MailerDraft(BaseScopedIdMixin, Model): +class MailerDraft(BaseScopedIdMixin[int, Account], Model): """Revision-controlled draft of mailer text (a Mustache template).""" __tablename__ = 'mailer_draft' mailer_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('mailer.id'), nullable=False + sa.ForeignKey('mailer.id'), default=None, nullable=False ) mailer: Mapped[Mailer] = relationship(back_populates='drafts') parent: Mapped[Mailer] = sa_orm.synonym('mailer') @@ -223,7 +214,7 @@ def get_preview(self) -> str: return self.mailer.render_preview(self.template) -class MailerRecipient(BaseScopedIdMixin, Model): +class MailerRecipient(BaseScopedIdMixin[int, Account], Model): """Recipient of a mailer.""" __tablename__ = 'mailer_recipient' @@ -259,11 +250,13 @@ class MailerRecipient(BaseScopedIdMixin, Model): # Support email open tracking opentoken: Mapped[str] = sa_orm.mapped_column( - sa.Unicode(44), nullable=False, default=newsecret, unique=True - ) - opened: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False + sa.Unicode(44), + nullable=False, + insert_default=newsecret, + default=None, + unique=True, ) + opened: Mapped[bool] = sa_orm.mapped_column(default=False) opened_ipaddr: Mapped[str | None] = sa_orm.mapped_column( sa.Unicode(45), nullable=True ) @@ -273,13 +266,15 @@ class MailerRecipient(BaseScopedIdMixin, Model): opened_last_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) - opened_count: Mapped[int] = sa_orm.mapped_column( - sa.Integer, nullable=False, default=0 - ) + opened_count: Mapped[int] = sa_orm.mapped_column(nullable=False, default=0) # Support RSVP if the email requires it rsvptoken: Mapped[str] = sa_orm.mapped_column( - sa.Unicode(44), nullable=False, default=newsecret, unique=True + sa.Unicode(44), + nullable=False, + insert_default=newsecret, + default=None, + unique=True, ) # Y/N/M response rsvp: Mapped[str | None] = sa_orm.mapped_column(sa.Unicode(1), nullable=True) @@ -301,7 +296,7 @@ class MailerRecipient(BaseScopedIdMixin, Model): # Draft of the mailer template that the custom template is linked to (for updating # before finalising) draft_id: Mapped[int | None] = sa_orm.mapped_column( - sa.ForeignKey('mailer_draft.id') + sa.ForeignKey('mailer_draft.id'), default=None ) draft: Mapped[MailerDraft | None] = relationship() diff --git a/funnel/models/membership_mixin.py b/funnel/models/membership_mixin.py index e3fc1c46b..c5d32f136 100644 --- a/funnel/models/membership_mixin.py +++ b/funnel/models/membership_mixin.py @@ -2,10 +2,11 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Iterable from datetime import datetime as datetime_type +from enum import ReprEnum from types import SimpleNamespace -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Self, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, Self, TypeVar from uuid import UUID from sqlalchemy import event @@ -14,12 +15,10 @@ from baseframe import __ from coaster.sqlalchemy import StateManager, immutable, with_roles -from coaster.utils import LabeledEnum from . import ( BaseMixin, Mapped, - Model, UuidMixin, db, declarative_mixin, @@ -30,11 +29,12 @@ sa_orm, ) from .account import Account -from .reorder_mixin import ReorderProtoMixin +from .helpers import IntTitle +from .reorder_mixin import ReorderMixin # Export only symbols needed in views. __all__ = [ - 'MEMBERSHIP_RECORD_TYPE', + 'MembershipRecordTypeEnum', 'MembershipError', 'MembershipRevokedError', 'MembershipRecordTypeError', @@ -43,29 +43,58 @@ # --- Typing --------------------------------------------------------------------------- MembershipType = TypeVar('MembershipType', bound='ImmutableMembershipMixin') + + +class MembershipMixinProtocol(Protocol): + member_id: Mapped[int] + member: declared_attr[Account] + _local_data_only: bool + parent_id_column: ClassVar[str] + + def replace(self, actor: Account, **data: Any) -> Self: + ... + + +class FrozenAttributionSubclassProtocol(MembershipMixinProtocol, Protocol): + _title: declared_attr[str | None] + + +class ReorderSubclassProtocol(Protocol): + seq: Mapped[Any] + parent_id: Mapped[Any] + parent: Mapped[Any] + is_active: hybrid_property[bool] + + @property + def parent_scoped_reorder_query_filter( + self: ReorderSubclassProtocol, + ) -> ColumnElement[bool]: + ... + + +MembershipMixinType = TypeVar('MembershipMixinType', bound=MembershipMixinProtocol) FrozenAttributionType = TypeVar( - 'FrozenAttributionType', bound='FrozenAttributionProtoMixin' + 'FrozenAttributionType', bound=FrozenAttributionSubclassProtocol ) + # --- Enum ----------------------------------------------------------------------------- -class MEMBERSHIP_RECORD_TYPE(LabeledEnum): # noqa: N801 +class MembershipRecordTypeEnum(IntTitle, ReprEnum): """Membership record types.""" - # TODO: Convert into IntEnum - #: An invite represents a potential future membership, but not a current membership - INVITE = (1, 'invite', __("Invite")) + INVITE = 1, __("Invite") #: An accept recognises a conversion from an invite into a current membership - ACCEPT = (2, 'accept', __("Accept")) + ACCEPT = 2, __("Accept") #: A direct add recognises a current membership without proof of consent - DIRECT_ADD = (3, 'direct_add', __("Direct add")) + DIRECT_ADD = 3, __("Direct add") #: An amendment is when data in the record has been changed - AMEND = (4, 'amend', __("Amend")) + AMEND = 4, __("Amend") #: A migrate record says this used to be some other form of membership and has been #: created due to a technical change in the product - # Forthcoming: MIGRATE = (5, 'migrate', __("Migrate")) + # Forthcoming: MIGRATE = 5, __("Migrate") # --- Exceptions ----------------------------------------------------------------------- @@ -87,25 +116,83 @@ class MembershipRecordTypeError(MembershipError): @declarative_mixin -class ImmutableMembershipMixin(UuidMixin, BaseMixin[UUID]): +class ImmutableMembershipMixin(UuidMixin, BaseMixin[UUID, Account]): """Support class for immutable memberships.""" - #: Can granted_by be null? Only in memberships based on legacy data - __null_granted_by__: ClassVar[bool] = False - #: List of columns that will be copied into a new row when a membership is amended - __data_columns__: ClassVar[Iterable[str]] = () - #: Name of the parent id column, used in SQL constraints - parent_id_column: ClassVar[str | None] if TYPE_CHECKING: #: Subclass has a table name __tablename__: str #: Parent column (declare as synonym of 'profile_id' or 'project_id' in #: subclasses) - parent_id: Mapped[int] | None + parent_id: Mapped[Any] | None #: Parent object - parent: Mapped[Model] | None - #: Subject of this membership (subclasses must define) - member: Mapped[Account] + parent: Mapped[Any] | None + + #: Can granted_by be null? Only in memberships based on legacy data + __null_granted_by__: ClassVar[bool] = False + #: List of columns that will be copied into a new row when a membership is amended + __data_columns__: ClassVar[Iterable[str]] = () + #: Name of the parent id column, used in SQL constraints + parent_id_column: ClassVar[str] + #: Foreign key column to account table + member_id: Mapped[int] = sa_orm.mapped_column( + sa.ForeignKey('account.id', ondelete='CASCADE'), + default=None, + nullable=False, + index=True, + ) + + @classmethod + def __member(cls) -> Mapped[Account]: + """Member in this membership record.""" + return relationship(Account, foreign_keys=[cls.member_id]) + + member = with_roles( + declared_attr(__member), + read={'member', 'editor'}, + grants_via={None: {'admin': 'member'}}, + ) + del __member + + @declared_attr + @classmethod + def user(cls) -> Mapped[Account]: + """Legacy alias for member in this membership record.""" + return sa_orm.synonym('member') + + __table_args__: tuple # pyright: ignore[reportGeneralTypeIssues] + + @declared_attr.directive # type: ignore[no-redef] + @classmethod + def __table_args__(cls) -> tuple: + """Table arguments for SQLAlchemy.""" + try: + args = list(super().__table_args__) # type: ignore[misc] + except AttributeError: + args = [] + kwargs = args.pop(-1) if args and isinstance(args[-1], dict) else None + if cls.parent_id_column: + args.append( + sa.Index( + 'ix_' + cls.__tablename__ + '_active', + cls.parent_id_column, + 'member_id', + unique=True, + postgresql_where='revoked_at IS NULL', + ), + ) + else: + args.append( + sa.Index( + 'ix_' + cls.__tablename__ + '_active', + 'member_id', + unique=True, + postgresql_where='revoked_at IS NULL', + ), + ) + if kwargs: + args.append(kwargs) + return tuple(args) #: Should an active membership record be revoked when the member is soft-deleted? #: (Hard deletes will cascade and also delete all membership records.) @@ -120,7 +207,10 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin[UUID]): granted_at: Mapped[datetime_type] = with_roles( immutable( sa_orm.mapped_column( - sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() + sa.TIMESTAMP(timezone=True), + nullable=False, + insert_default=sa.func.utcnow(), + default=None, ) ), read={'member', 'editor'}, @@ -134,9 +224,10 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin[UUID]): record_type: Mapped[int] = with_roles( immutable( sa_orm.mapped_column( - sa.Integer, - StateManager.check_constraint('record_type', MEMBERSHIP_RECORD_TYPE), - default=MEMBERSHIP_RECORD_TYPE.DIRECT_ADD, + StateManager.check_constraint( + 'record_type', MembershipRecordTypeEnum, sa.Integer + ), + default=MembershipRecordTypeEnum.DIRECT_ADD, nullable=False, ) ), @@ -144,17 +235,19 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin[UUID]): ) @cached_property - def record_type_label(self): - return MEMBERSHIP_RECORD_TYPE[self.record_type] + def record_type_enum(self): + return MembershipRecordTypeEnum(self.record_type) - with_roles(record_type_label, read={'member', 'editor'}) + with_roles(record_type_enum, read={'member', 'editor'}) @declared_attr @classmethod def revoked_by_id(cls) -> Mapped[int | None]: """Id of user who revoked the membership.""" return sa_orm.mapped_column( - sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=True + sa.ForeignKey('account.id', ondelete='SET NULL'), + default=None, + nullable=True, ) @with_roles(read={'member', 'editor'}, grants={'editor'}) @@ -174,8 +267,8 @@ def granted_by_id(cls) -> Mapped[int | None]: for granted_by. """ return sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('account.id', ondelete='SET NULL'), + default=None, nullable=cls.__null_granted_by__, ) @@ -191,7 +284,7 @@ def is_active(self) -> bool: """Test if membership record is active (not revoked, not an invite).""" return ( self.revoked_at is None - and self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE + and self.record_type != MembershipRecordTypeEnum.INVITE ) @is_active.inplace.expression @@ -199,7 +292,7 @@ def is_active(self) -> bool: def _is_active_expression(cls) -> sa.ColumnElement[bool]: """Test if membership record is active as a SQL expression.""" return sa.and_( - cls.revoked_at.is_(None), cls.record_type != MEMBERSHIP_RECORD_TYPE.INVITE + cls.revoked_at.is_(None), cls.record_type != MembershipRecordTypeEnum.INVITE ) with_roles(is_active, read={'member'}) @@ -207,14 +300,14 @@ def _is_active_expression(cls) -> sa.ColumnElement[bool]: @hybrid_property def is_invite(self) -> bool: """Test if membership record is an invitation.""" - return self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE + return self.record_type == MembershipRecordTypeEnum.INVITE with_roles(is_invite, read={'member', 'editor'}) @hybrid_property def is_amendment(self) -> bool: """Test if membership record is an amendment.""" - return self.record_type == MEMBERSHIP_RECORD_TYPE.AMEND + return self.record_type == MembershipRecordTypeEnum.AMEND with_roles(is_amendment, read={'member', 'editor'}) @@ -243,14 +336,8 @@ def revoke(self, actor: Account) -> None: self.revoked_at = sa.func.utcnow() self.revoked_by = actor - def copy_template(self: MembershipType, **kwargs) -> MembershipType: - """Make a copy of self for customization.""" - raise NotImplementedError("Subclasses must implement copy_template") - @with_roles(call={'editor'}) - def replace( - self: MembershipType, actor: Account, _accept: bool = False, **data: Any - ) -> MembershipType: + def replace(self, actor: Account, _accept: bool = False, **data: Any) -> Self: """Replace this membership record with changes to role columns.""" if self.revoked_at is not None: raise MembershipRevokedError( @@ -261,7 +348,7 @@ def replace( # Perform sanity check. If nothing changed, just return self has_changes = False - if self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE and _accept: + if self.record_type == MembershipRecordTypeEnum.INVITE and _accept: # If the existing record is an INVITE and this is an ACCEPT, we have # a record change even if no data changed has_changes = True @@ -287,13 +374,13 @@ def replace( # if existing record type is INVITE, then ACCEPT or amend as new INVITE # else replace it with AMEND - if self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE: + if self.record_type == MembershipRecordTypeEnum.INVITE: if _accept: - new.record_type = MEMBERSHIP_RECORD_TYPE.ACCEPT + new.record_type = MembershipRecordTypeEnum.ACCEPT else: - new.record_type = MEMBERSHIP_RECORD_TYPE.INVITE + new.record_type = MembershipRecordTypeEnum.INVITE else: - new.record_type = MEMBERSHIP_RECORD_TYPE.AMEND + new.record_type = MembershipRecordTypeEnum.AMEND self._local_data_only = True for column in self.__data_columns__: @@ -310,9 +397,7 @@ def amend_by(self, actor: Account): """Amend a membership in a `with` context.""" return AmendMembership(self, actor) - def merge_and_replace( - self: MembershipType, actor: Account, other: MembershipType - ) -> MembershipType: + def merge_and_replace(self, actor: Account, other: Self) -> Self: """Replace this record by merging data from an independent record.""" if self.__class__ is not other.__class__: raise TypeError("Merger requires membership records of the same type") @@ -322,8 +407,8 @@ def merge_and_replace( raise MembershipRevokedError("Can't merge with a revoked membership record") if ( - self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE - and other.record_type != MEMBERSHIP_RECORD_TYPE.INVITE + self.record_type == MembershipRecordTypeEnum.INVITE + and other.record_type != MembershipRecordTypeEnum.INVITE ): # If we are an INVITE but the other is not an INVITE, then we must ACCEPT # the INVITE before proceeding to an AMEND merger @@ -348,87 +433,14 @@ def merge_and_replace( return replacement @with_roles(call={'member'}) - def accept(self: MembershipType, actor: Account) -> MembershipType: + def accept(self, actor: Account) -> Self: """Accept a membership invitation.""" - if self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE: + if self.record_type != MembershipRecordTypeEnum.INVITE: raise MembershipRecordTypeError("This membership record is not an invite") if 'member' not in self.roles_for(actor): raise ValueError("Invite must be accepted by the invited user") return self.replace(actor, _accept=True) - @with_roles(call={'owner', 'member'}) - def freeze_member_attribution( - self: MembershipType, actor: Account - ) -> MembershipType: - """ - Freeze member attribution and return a replacement record. - - Subclasses that support member attribution must override this method. The - default implementation returns `self`. - """ - return self - - -@declarative_mixin -class ImmutableUserMembershipMixin(ImmutableMembershipMixin): - """Support class for immutable memberships for users.""" - - @declared_attr - @classmethod - def member_id(cls) -> Mapped[int]: - """Foreign key column to account table.""" - return sa_orm.mapped_column( - sa.Integer, - sa.ForeignKey('account.id', ondelete='CASCADE'), - nullable=False, - index=True, - ) - - @with_roles(read={'member', 'editor'}, grants_via={None: {'admin': 'member'}}) - @declared_attr - @classmethod - def member(cls) -> Mapped[Account]: # type: ignore[override] - """Member in this membership record.""" - return relationship(Account, foreign_keys=[cls.member_id]) - - @declared_attr - @classmethod - def user(cls) -> Mapped[Account]: - """Legacy alias for member in this membership record.""" - return sa_orm.synonym('member') - - @declared_attr.directive - @classmethod - def __table_args__(cls) -> tuple: - """Table arguments for SQLAlchemy.""" - try: - args = list(super().__table_args__) # type: ignore[misc] - except AttributeError: - args = [] - kwargs = args.pop(-1) if args and isinstance(args[-1], dict) else None - if cls.parent_id_column is not None: - args.append( - sa.Index( - 'ix_' + cls.__tablename__ + '_active', - cls.parent_id_column, - 'member_id', - unique=True, - postgresql_where='revoked_at IS NULL', - ), - ) - else: - args.append( - sa.Index( - 'ix_' + cls.__tablename__ + '_active', - 'member_id', - unique=True, - postgresql_where='revoked_at IS NULL', - ), - ) - if kwargs: - args.append(kwargs) - return tuple(args) - @hybrid_property def is_self_granted(self) -> bool: """Return True if the member in this record is also the granting actor.""" @@ -447,8 +459,9 @@ def is_self_revoked(self) -> bool: with_roles(is_self_revoked, read={'member', 'editor'}) - def copy_template(self: MembershipType, **kwargs) -> MembershipType: - return type(self)(member=self.member, **kwargs) # type: ignore + def copy_template(self, **kwargs) -> Self: + """Make a copy of self for customization.""" + return self.__class__(member=self.member, **kwargs) # type: ignore[call-arg] @classmethod def migrate_account(cls, old_account: Account, new_account: Account) -> None: @@ -503,12 +516,9 @@ def migrate_account(cls, old_account: Account, new_account: Account) -> None: @declarative_mixin -class ReorderMembershipProtoMixin(ReorderProtoMixin): +class ReorderMembershipMixin(ImmutableMembershipMixin, ReorderMixin): """Customizes ReorderMixin for membership models.""" - if TYPE_CHECKING: - parent_id_column: ClassVar[str] - #: Sequence number. Not immutable, and may be overwritten by ReorderMixin as a #: side-effect of reordering other records. This is not considered a revision. #: However, it can be argued that relocating a sponsor in the list constitutes a @@ -516,19 +526,21 @@ class ReorderMembershipProtoMixin(ReorderProtoMixin): #: on `seq` being mutable in a future iteration. seq: Mapped[int] = sa_orm.mapped_column(nullable=False) - @declared_attr.directive + __table_args__: tuple # pyright: ignore[reportGeneralTypeIssues] + + @declared_attr.directive # type: ignore[no-redef] @classmethod - def __table_args__(cls) -> tuple: + def __table_args__(cls) -> tuple: # type: ignore[override] """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 # Add unique constraint on :attr:`seq` for active records args.append( sa.Index( - 'ix_' + cls.__tablename__ + '_seq', # type: ignore[attr-defined] + 'ix_' + cls.__tablename__ + '_seq', cls.parent_id_column, 'seq', unique=True, @@ -539,18 +551,20 @@ def __table_args__(cls) -> tuple: args.append(kwargs) return tuple(args) - def __init__(self, **kwargs) -> None: + def __init__(self: ReorderSubclassProtocol, **kwargs) -> None: super().__init__(**kwargs) # Assign a default value to `seq` if self.seq is None: # Will be None until first commit - self.seq = ( # type: ignore[unreachable] + self.seq = ( sa.select(sa.func.coalesce(sa.func.max(self.__class__.seq) + 1, 1)) .where(self.parent_scoped_reorder_query_filter) .scalar_subquery() ) @property - def parent_scoped_reorder_query_filter(self) -> ColumnElement: + def parent_scoped_reorder_query_filter( + self: ReorderSubclassProtocol, + ) -> ColumnElement[bool]: """ Return a query filter that includes a scope limitation to active records. @@ -564,26 +578,20 @@ def parent_scoped_reorder_query_filter(self) -> ColumnElement: if self.parent_id is not None: return sa.and_( cls.parent_id == self.parent_id, - cls.is_active, # type: ignore[attr-defined] + cls.is_active, ) - return sa.and_( # type: ignore[unreachable] + return sa.and_( cls.parent == self.parent, cls.is_active, ) @declarative_mixin -class FrozenAttributionProtoMixin: +class FrozenAttributionMixin: """Provides a `title` data column and support method to freeze it.""" - if TYPE_CHECKING: - member: Mapped[Account] - replace: Callable[..., Self] - _local_data_only: bool - - @declared_attr @classmethod - def _title(cls) -> Mapped[str | None]: + def __title(cls) -> Mapped[str | None]: """Create optional attribution title for this membership record.""" return immutable( sa_orm.mapped_column( @@ -591,8 +599,11 @@ def _title(cls) -> Mapped[str | None]: ) ) + _title = declared_attr(__title) + del __title + @property - def title(self) -> str: + def title(self: FrozenAttributionSubclassProtocol) -> str: """Attribution title for this record.""" if self._local_data_only: # self._title may be None when returning local data @@ -607,12 +618,14 @@ def title(self, value: str | None) -> None: self._title = value or None # Don't set empty string @property - def pickername(self) -> str: + def pickername(self: FrozenAttributionSubclassProtocol) -> str: """Return member's pickername, but only if attribution isn't frozen.""" return self._title if self._title else self.member.pickername @with_roles(call={'owner', 'member'}) - def freeze_member_attribution(self, actor: Account) -> Self: + def freeze_member_attribution( + self: FrozenAttributionType, actor: Account + ) -> FrozenAttributionType: """Freeze member attribution and return a replacement record.""" if self._title is None: membership = self.replace(actor=actor, title=self.member.title) @@ -691,7 +704,7 @@ def _confirm_enumerated_mixins(_mapper: Any, cls: type[Account]) -> None: """Confirm that the membership collection attributes actually exist.""" expected_class = ImmutableMembershipMixin if issubclass(cls, Account): - expected_class = ImmutableUserMembershipMixin + expected_class = ImmutableMembershipMixin for source in ( cls.__active_membership_attrs__, cls.__noninvite_membership_attrs__, diff --git a/funnel/models/moderation.py b/funnel/models/moderation.py index 95a388603..08cd28e53 100644 --- a/funnel/models/moderation.py +++ b/funnel/models/moderation.py @@ -22,25 +22,30 @@ class MODERATOR_REPORT_TYPE(LabeledEnum): # noqa: N801 SPAM = (2, 'spam', __("Spam")) -class CommentModeratorReport(UuidMixin, BaseMixin[UUID], Model): +class CommentModeratorReport(UuidMixin, BaseMixin[UUID, Account], Model): __tablename__ = 'comment_moderator_report' comment_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('comment.id'), nullable=False, index=True + sa.ForeignKey('comment.id'), default=None, nullable=False, index=True ) 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 + sa.ForeignKey('account.id'), default=None, nullable=False, index=True ) 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), + StateManager.check_constraint( + 'report_type', MODERATOR_REPORT_TYPE, sa.SmallInteger + ), nullable=False, default=MODERATOR_REPORT_TYPE.SPAM, ) reported_at: Mapped[datetime] = sa_orm.mapped_column( - sa.TIMESTAMP(timezone=True), default=sa.func.utcnow(), nullable=False + sa.TIMESTAMP(timezone=True), + insert_default=sa.func.utcnow(), + default=None, + nullable=False, ) resolved_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True diff --git a/funnel/models/notification.py b/funnel/models/notification.py index 145dea0de..50407cbe4 100644 --- a/funnel/models/notification.py +++ b/funnel/models/notification.py @@ -84,7 +84,8 @@ from collections.abc import Callable, Generator, Sequence from dataclasses import dataclass from datetime import datetime -from types import SimpleNamespace +from enum import ReprEnum +from types import SimpleNamespace, UnionType from typing import ( Any, ClassVar, @@ -113,7 +114,7 @@ immutable, with_roles, ) -from coaster.utils import LabeledEnum, uuid_from_base58, uuid_to_base58 +from coaster.utils import utcnow, uuid_from_base58, uuid_to_base58 from ..typing import T from . import ( @@ -131,11 +132,12 @@ sa_orm, ) from .account import Account, AccountEmail, AccountPhone +from .helpers import IntTitle from .phone_number import PhoneNumber, PhoneNumberMixin -from .typing import UuidModelUnion +from .typing import ModelUuidProtocol __all__ = [ - 'SMS_STATUS', + 'SmsStatusEnum', 'notification_categories', 'SmsMessage', 'NotificationType', @@ -151,9 +153,9 @@ # --- Typing --------------------------------------------------------------------------- # Document generic type -_D = TypeVar('_D', bound=UuidModelUnion) +_D = TypeVar('_D', bound=ModelUuidProtocol) # Fragment generic type -_F = TypeVar('_F', bound=Optional[UuidModelUnion]) +_F = TypeVar('_F', bound=ModelUuidProtocol | None) # Type of None (required to detect Optional) NoneType = type(None) @@ -219,20 +221,20 @@ class NotificationCategory: # --- Flags ---------------------------------------------------------------------------- -class SMS_STATUS(LabeledEnum): # noqa: N801 +class SmsStatusEnum(IntTitle, ReprEnum): """SMS delivery status.""" - QUEUED = (1, __("Queued")) - PENDING = (2, __("Pending")) - DELIVERED = (3, __("Delivered")) - FAILED = (4, __("Failed")) - UNKNOWN = (5, __("Unknown")) + QUEUED = 1, __("Queued") + PENDING = 2, __("Pending") + DELIVERED = 3, __("Delivered") + FAILED = 4, __("Failed") + UNKNOWN = 5, __("Unknown") # --- Legacy models -------------------------------------------------------------------- -class SmsMessage(PhoneNumberMixin, BaseMixin, Model): +class SmsMessage(PhoneNumberMixin, BaseMixin[int, Account], Model): """An outbound SMS message.""" __tablename__ = 'sms_message' @@ -249,7 +251,7 @@ class SmsMessage(PhoneNumberMixin, BaseMixin, Model): ) # Flags status: Mapped[int] = sa_orm.mapped_column( - sa.Integer, default=SMS_STATUS.QUEUED, nullable=False + default=SmsStatusEnum.QUEUED, nullable=False ) status_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True @@ -271,6 +273,9 @@ def __init__(self, **kwargs) -> None: class NotificationType(Generic[_D, _F], Protocol): """Protocol for :class:`Notification` and :class:`PreviewNotification`.""" + preference_context: ClassVar[Any] + for_private_recipient: bool + type: str # noqa: A003 eventid: UUID id: UUID # noqa: A003 @@ -279,6 +284,7 @@ class NotificationType(Generic[_D, _F], Protocol): document_uuid: UUID fragment: _F | None fragment_uuid: UUID | None + created_at: datetime created_by_id: int | None created_by: Account | None @@ -306,14 +312,22 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): #: instance of a UserNotification per-event rather than per-notification eventid: Mapped[UUID] = immutable( sa_orm.mapped_column( - postgresql.UUID, primary_key=True, nullable=False, default=uuid4 + postgresql.UUID, + primary_key=True, + nullable=False, + insert_default=uuid4, + default=None, ) ) #: Notification id id: Mapped[UUID] = immutable( # noqa: A003 sa_orm.mapped_column( - postgresql.UUID, primary_key=True, nullable=False, default=uuid4 + postgresql.UUID, + primary_key=True, + nullable=False, + insert_default=uuid4, + default=None, ) ) @@ -330,12 +344,12 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): pref_type: ClassVar[str] = '' #: Document model, must be specified in subclasses - document_model: ClassVar[type[UuidModelUnion]] + document_model: ClassVar[type[ModelUuidProtocol]] #: SQL table name for document type, auto-populated from the document model document_type: ClassVar[str] #: Fragment model, optional for subclasses - fragment_model: ClassVar[type[UuidModelUnion] | None] = None + fragment_model: ClassVar[type[ModelUuidProtocol] | None] = None #: SQL table name for fragment type, auto-populated from the fragment model fragment_type: ClassVar[str | None] @@ -355,13 +369,13 @@ class Notification(NoIdMixin, Model, Generic[_D, _F]): preference_context: ClassVar[Any] = None #: Notification type (identifier for subclass of :class:`NotificationType`) - type_: Mapped[str] = immutable( - sa_orm.mapped_column('type', sa.Unicode, nullable=False) - ) + type_: Mapped[str] = immutable(sa_orm.mapped_column('type')) #: Id of user that triggered this notification created_by_id: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=True + sa.ForeignKey('account.id', ondelete='SET NULL'), + default=None, + nullable=True, ) #: User that triggered this notification. Optional, as not all notifications are #: caused by user activity. Used to optionally exclude user from receiving @@ -492,7 +506,7 @@ def __init_subclass__( # pylint: disable=arguments-differ fragment_model = None elif get_origin(fragment_model) is Optional: fragment_model = get_args(fragment_model)[0] - elif get_origin(fragment_model) is Union: + elif get_origin(fragment_model) in (Union, UnionType): _union_args = get_args(fragment_model) if len(_union_args) == 2 and _union_args[1] is NoneType: fragment_model = _union_args[0] @@ -553,7 +567,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 # type: ignore[union-attr] + kwargs['fragment_uuid'] = fragment.uuid super().__init__(**kwargs) @property @@ -634,9 +648,9 @@ def allow_transport(cls, transport: str) -> bool: return getattr(cls, 'allow_' + transport) @property - def role_provider_obj(self) -> _F | _D: + def role_provider_obj(self) -> ModelUuidProtocol: """Return fragment if exists, document otherwise, indicating role provider.""" - return cast(_F | _D, self.fragment or self.document) + return self.fragment or self.document # type: ignore[return-value] # FIXME def dispatch(self) -> Generator[NotificationRecipient, None, None]: """ @@ -678,8 +692,8 @@ def dispatch(self) -> Generator[NotificationRecipient, None, None]: # Since this query uses SQLAlchemy's session cache, we don't have to # bother with a local cache for the first case. - existing_notification = NotificationRecipient.query.get( - (account.id, self.eventid) + existing_notification = db.session.get( + NotificationRecipient, (account.id, self.eventid) ) if existing_notification is None: recipient = NotificationRecipient( @@ -709,11 +723,13 @@ class PreviewNotification(NotificationType): ) """ + preference_context = None + def __init__( # pylint: disable=super-init-not-called self, cls: type[Notification], - document: UuidModelUnion, - fragment: UuidModelUnion | None = None, + document: ModelUuidProtocol, + fragment: ModelUuidProtocol | None = None, user: Account | None = None, ) -> None: self.eventid = uuid4() @@ -721,12 +737,14 @@ def __init__( # pylint: disable=super-init-not-called self.eventid_b58 = uuid_to_base58(self.eventid) self.cls = cls self.type = cls.cls_type + self.for_private_recipient = cls.for_private_recipient self.document = document self.document_uuid = document.uuid self.fragment = fragment self.fragment_uuid = fragment.uuid if fragment is not None else None + self.created_at = utcnow() self.created_by = user - self.created_by_id = cast(int, user.id) if user is not None else None + self.created_by_id = user.id if user is not None else None def __getattr__(self, attr: str) -> Any: """Get an attribute.""" @@ -756,14 +774,14 @@ def notification_pref_type(self) -> str: with_roles(notification_pref_type, read={'owner'}) @cached_property - def document(self) -> UuidModelUnion | None: + def document(self) -> ModelUuidProtocol | None: """Document that this notification is for.""" return self.notification.document with_roles(document, read={'owner'}) @cached_property - def fragment(self) -> UuidModelUnion | None: + def fragment(self) -> ModelUuidProtocol | None: """Fragment within this document that this notification is for.""" return self.notification.fragment @@ -784,7 +802,7 @@ def is_not_deleted(self, revoke: bool = False) -> bool: pass if revoke: self.is_revoked = True - # Do not set self.rollupid because this is not a rollup + # Do not set `self.rollupid` because this is not a rollup return False @@ -802,9 +820,9 @@ class NotificationRecipient(NoIdMixin, NotificationRecipientProtoMixin, Model): #: Id of user being notified recipient_id: Mapped[int] = immutable( sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('account.id', ondelete='CASCADE'), primary_key=True, + default=None, nullable=False, ) ) @@ -839,9 +857,7 @@ class NotificationRecipient(NoIdMixin, NotificationRecipientProtoMixin, Model): #: Note: This column represents the first instance of a role shifting from being an #: 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'} - ) + role: Mapped[str] = with_roles(immutable(sa_orm.mapped_column()), read={'owner'}) #: Timestamp for when this notification was marked as read read_at: Mapped[datetime | None] = with_roles( @@ -866,23 +882,15 @@ class NotificationRecipient(NoIdMixin, NotificationRecipientProtoMixin, Model): ) #: Message id for email delivery - messageid_email: Mapped[str | None] = sa_orm.mapped_column( - sa.Unicode, nullable=True - ) + messageid_email: Mapped[str | None] = sa_orm.mapped_column(default=None) #: 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(default=None) #: Message id for web push delivery - messageid_webpush: Mapped[str | None] = sa_orm.mapped_column( - sa.Unicode, nullable=True - ) + messageid_webpush: Mapped[str | None] = sa_orm.mapped_column(default=None) #: Message id for Telegram delivery - messageid_telegram: Mapped[str | None] = sa_orm.mapped_column( - sa.Unicode, nullable=True - ) + messageid_telegram: Mapped[str | None] = sa_orm.mapped_column(default=None) #: Message id for WhatsApp delivery - messageid_whatsapp: Mapped[str | None] = sa_orm.mapped_column( - sa.Unicode, nullable=True - ) + messageid_whatsapp: Mapped[str | None] = sa_orm.mapped_column(default=None) __table_args__ = ( sa.ForeignKeyConstraint( @@ -1154,7 +1162,7 @@ def rolledup_fragments(self) -> Query | None: @classmethod def get_for(cls, user: Account, eventid_b58: str) -> NotificationRecipient | None: """Retrieve a :class:`UserNotification` using SQLAlchemy session cache.""" - return cls.query.get((user.id, uuid_from_base58(eventid_b58))) + return db.session.get(cls, (user.id, uuid_from_base58(eventid_b58))) @classmethod def web_notifications_for( @@ -1190,7 +1198,9 @@ def migrate_account(cls, old_account: Account, new_account: Account) -> None: for notification_recipient in cls.query.filter_by( recipient_id=old_account.id ).all(): - existing = cls.query.get((new_account.id, notification_recipient.eventid)) + existing = db.session.get( + cls, (new_account.id, notification_recipient.eventid) + ) # TODO: Instead of dropping old_user's dupe notifications, check which of # the two has a higher priority role and keep that. This may not be possible # if the two copies are for different notifications under the same eventid. @@ -1245,15 +1255,15 @@ def rolledup_fragments(self) -> Query | None: # --- Notification preferences --------------------------------------------------------- -class NotificationPreferences(BaseMixin, Model): +class NotificationPreferences(BaseMixin[int, Account], Model): """Holds a user's preferences for a particular :class:`Notification` type.""" __tablename__ = 'notification_preferences' #: Id of account whose preferences are represented here account_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('account.id', ondelete='CASCADE'), + default=None, nullable=False, index=True, ) @@ -1266,25 +1276,13 @@ 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) - ) + notification_type: Mapped[str] = immutable(sa_orm.mapped_column()) - by_email: Mapped[bool] = with_roles( - 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'} - ) - by_webpush: Mapped[bool] = with_roles( - 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'} - ) - by_whatsapp: Mapped[bool] = with_roles( - sa_orm.mapped_column(sa.Boolean, nullable=False), rw={'owner'} - ) + by_email: Mapped[bool] = with_roles(sa_orm.mapped_column(), rw={'owner'}) + by_sms: Mapped[bool] = with_roles(sa_orm.mapped_column(), rw={'owner'}) + by_webpush: Mapped[bool] = with_roles(sa_orm.mapped_column(), rw={'owner'}) + by_telegram: Mapped[bool] = with_roles(sa_orm.mapped_column(), rw={'owner'}) + by_whatsapp: Mapped[bool] = with_roles(sa_orm.mapped_column(), rw={'owner'}) __table_args__ = (sa.UniqueConstraint('account_id', 'notification_type'),) diff --git a/funnel/models/notification_types.py b/funnel/models/notification_types.py index ca11d5a04..3ba50ba9b 100644 --- a/funnel/models/notification_types.py +++ b/funnel/models/notification_types.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - from baseframe import __ from .account import Account @@ -150,7 +148,7 @@ class ProposalSubmittedNotification( class ProjectStartingNotification( DocumentHasAccount, - Notification[Project, Optional[Session]], + Notification[Project, Session | None], type='project_starting', ): """Notification of a session about to start.""" diff --git a/funnel/models/phone_number.py b/funnel/models/phone_number.py index 2eacfb36e..619faa288 100644 --- a/funnel/models/phone_number.py +++ b/funnel/models/phone_number.py @@ -235,7 +235,7 @@ def phone_blake2b160_hash( # --- Models --------------------------------------------------------------------------- -class PhoneNumber(BaseMixin, Model): +class PhoneNumber(BaseMixin[int, 'Account'], Model): """ Represents a phone number as a standalone entity, with associated metadata. @@ -263,9 +263,7 @@ class PhoneNumber(BaseMixin, Model): #: 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( - sa.Unicode, nullable=True, unique=True - ) + number: Mapped[str | None] = sa_orm.mapped_column(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, @@ -287,13 +285,13 @@ 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() #: Timestamp at which this number was determined to be valid/invalid for SMS 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() #: Timestamp at which this number was tested for availability on WhatsApp has_wa_at: Mapped[datetime | None] = sa_orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True @@ -768,8 +766,8 @@ class OptionalPhoneNumberMixin: def phone_number_id(cls) -> Mapped[int | None]: """Foreign key to phone_number table.""" return sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('phone_number.id', ondelete='SET NULL'), + default=None, nullable=cls.__phone_optional__, unique=cls.__phone_unique__, index=not cls.__phone_unique__, diff --git a/funnel/models/project.py b/funnel/models/project.py index a77422729..5067b2694 100644 --- a/funnel/models/project.py +++ b/funnel/models/project.py @@ -6,6 +6,7 @@ from collections import OrderedDict, defaultdict from collections.abc import Sequence from datetime import datetime, timedelta +from enum import ReprEnum from typing import TYPE_CHECKING, Any, Literal, Self, cast, overload from flask_babel import format_date, get_locale @@ -44,17 +45,17 @@ types, ) from .account import Account -from .comment import SET_TYPE, Commentset from .helpers import ( RESERVED_NAMES, ImgeeType, + IntTitle, MarkdownCompositeDocument, add_search_trigger, valid_name, visual_field_delimiter, ) -__all__ = ['PROJECT_RSVP_STATE', 'Project', 'ProjectLocation', 'ProjectRedirect'] +__all__ = ['ProjectRsvpStateEnum', 'Project', 'ProjectLocation', 'ProjectRedirect'] # --- Constants --------------------------------------------------------------- @@ -76,25 +77,25 @@ class CFP_STATE(LabeledEnum): # noqa: N801 ANY = {NONE, PUBLIC, CLOSED} -class PROJECT_RSVP_STATE(LabeledEnum): # noqa: N801 - NONE = (1, __("Not accepting registrations")) - ALL = (2, __("Anyone can register")) - MEMBERS = (3, __("Only members can register")) +class ProjectRsvpStateEnum(IntTitle, ReprEnum): + NONE = 1, __("Not accepting registrations") + ALL = 2, __("Anyone can register") + MEMBERS = 3, __("Only members can register") # --- Models ------------------------------------------------------------------ -class Project(UuidMixin, BaseScopedNameMixin, Model): +class Project(UuidMixin, BaseScopedNameMixin[int, Account], Model): __tablename__ = 'project' reserved_names = RESERVED_NAMES created_by_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None, nullable=False ) created_by: Mapped[Account] = relationship(foreign_keys=[created_by_id]) account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None, nullable=False ) account: Mapped[Account] = with_roles( relationship(foreign_keys=[account_id], back_populates='projects'), @@ -141,15 +142,19 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): 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, + insert_default=utc, + default=None, + ), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) _state: Mapped[int] = sa_orm.mapped_column( 'state', - sa.Integer, - StateManager.check_constraint('state', PROJECT_STATE), + StateManager.check_constraint('state', PROJECT_STATE, sa.Integer), default=PROJECT_STATE.DRAFT, nullable=False, index=True, @@ -160,8 +165,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): ) _cfp_state: Mapped[int] = sa_orm.mapped_column( 'cfp_state', - sa.Integer, - StateManager.check_constraint('cfp_state', CFP_STATE), + StateManager.check_constraint('cfp_state', CFP_STATE, sa.Integer), default=CFP_STATE.NONE, nullable=False, index=True, @@ -174,8 +178,10 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): rsvp_state: Mapped[int] = with_roles( sa_orm.mapped_column( sa.SmallInteger, - StateManager.check_constraint('rsvp_state', PROJECT_RSVP_STATE), - default=PROJECT_RSVP_STATE.NONE, + StateManager.check_constraint( + 'rsvp_state', ProjectRsvpStateEnum, sa.SmallInteger + ), + default=ProjectRsvpStateEnum.NONE, nullable=False, ), read={'all'}, @@ -251,11 +257,11 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): 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(default=8, nullable=True), read={'all'} ) commentset_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('commentset.id'), nullable=False + sa.ForeignKey('commentset.id'), default=None, nullable=False ) commentset: Mapped[Commentset] = relationship( uselist=False, single_parent=True, back_populates='project' @@ -264,6 +270,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): parent_project_id: Mapped[int | None] = sa_orm.mapped_column( 'parent_id', # TODO: Migration required sa.ForeignKey('project.id', ondelete='SET NULL'), + default=None, nullable=True, ) parent_project: Mapped[Project | None] = relationship( @@ -274,7 +281,7 @@ 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(default=False), read={'all'}, write={'site_editor'}, datasets={'primary', 'without_parent'}, @@ -285,21 +292,20 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): sa.ARRAY(sa.UnicodeText, dimensions=1), nullable=True, # For legacy data server_default=sa.text("'{}'::text[]"), + default=None, ), read={'all'}, datasets={'primary', 'without_parent'}, ) is_restricted_video: Mapped[bool] = with_roles( - sa_orm.mapped_column(sa.Boolean, default=False, nullable=False), + sa_orm.mapped_column(default=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'} - ) + revisionid: Mapped[int] = with_roles(sa_orm.mapped_column(), read={'all'}) search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( @@ -357,7 +363,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): lazy='dynamic', primaryjoin=lambda: sa.and_( ProjectMembership.project_id == Project.id, - ProjectMembership.is_active, + ProjectMembership.is_active, # type: ignore[has-type] # FIXME ), viewonly=True, ), @@ -368,7 +374,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): lazy='dynamic', primaryjoin=lambda: sa.and_( ProjectMembership.project_id == Project.id, - ProjectMembership.is_active, + ProjectMembership.is_active, # type: ignore[has-type] # FIXME ProjectMembership.is_editor.is_(True), ), viewonly=True, @@ -378,7 +384,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): lazy='dynamic', primaryjoin=lambda: sa.and_( ProjectMembership.project_id == Project.id, - ProjectMembership.is_active, + ProjectMembership.is_active, # type: ignore[has-type] # FIXME ProjectMembership.is_promoter.is_(True), ), viewonly=True, @@ -388,7 +394,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): lazy='dynamic', primaryjoin=lambda: sa.and_( ProjectMembership.project_id == Project.id, - ProjectMembership.is_active, + ProjectMembership.is_active, # type: ignore[has-type] # FIXME ProjectMembership.is_usher.is_(True), ), viewonly=True, @@ -437,7 +443,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): lazy='dynamic', primaryjoin=lambda: sa.and_( ProjectSponsorMembership.project_id == Project.id, - ProjectSponsorMembership.is_active, + ProjectSponsorMembership.is_active, # type: ignore[has-type] # FIXME ), order_by=lambda: ProjectSponsorMembership.seq, viewonly=True, @@ -895,7 +901,7 @@ def end_at_localized(self): @hybrid_property def allow_rsvp(self) -> bool: """RSVP state as a boolean value (allowed for all or not).""" - return self.rsvp_state == PROJECT_RSVP_STATE.ALL + return self.rsvp_state == ProjectRsvpStateEnum.ALL @property def active_rsvps(self) -> Query[Rsvp]: @@ -912,16 +918,12 @@ def rsvp_for( ... def rsvp_for(self, account: Account | None, create=False) -> Rsvp | None: - return Rsvp.get_for(cast(Project, self), account, create) + return Rsvp.get_for(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 rsvps_with(self, state: RsvpStateEnum) -> Query[Rsvp]: + return self.rsvps.join(Account).filter( + Account.state.ACTIVE, + Rsvp._state == state, # pylint: disable=protected-access ) def rsvp_counts(self) -> dict[str, int]: @@ -940,8 +942,7 @@ def rsvp_counts(self) -> dict[str, int]: @cached_property def rsvp_count_going(self) -> int: return ( - cast(Project, self) - .rsvps.join(Account) + self.rsvps.join(Account) .filter(Account.state.ACTIVE, Rsvp.state.YES) .count() ) @@ -1459,7 +1460,7 @@ class ProjectRedirect(TimestampMixin, Model): __tablename__ = 'project_redirect' account_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False, primary_key=True + sa.ForeignKey('account.id'), default=None, nullable=False, primary_key=True ) account: Mapped[Account] = relationship(back_populates='project_redirects') parent: Mapped[Account] = sa_orm.synonym('account') @@ -1468,7 +1469,7 @@ class ProjectRedirect(TimestampMixin, Model): ) project_id: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id', ondelete='SET NULL'), nullable=True + sa.ForeignKey('project.id', ondelete='SET NULL'), default=None, nullable=True ) project: Mapped[Project | None] = relationship(back_populates='redirects') @@ -1508,7 +1509,7 @@ def add( account = project.account if name is None: name = project.name - redirect = cls.query.get((account.id, name)) + redirect = db.session.get(cls, (account.id, name)) if redirect is None: redirect = cls(account=account, name=name, project=project) db.session.add(redirect) @@ -1539,16 +1540,14 @@ class ProjectLocation(TimestampMixin, Model): __tablename__ = 'project_location' #: Project we are tagging project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), primary_key=True, nullable=False + sa.ForeignKey('project.id'), default=None, primary_key=True, nullable=False ) project: Mapped[Project] = relationship(back_populates='locations') #: Geonameid for this project geonameid: Mapped[int] = sa_orm.mapped_column( - sa.Integer, primary_key=True, nullable=False, index=True - ) - primary: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, default=True, nullable=False + primary_key=True, nullable=False, index=True ) + primary: Mapped[bool] = sa_orm.mapped_column(default=True, nullable=False) def __repr__(self) -> str: """Represent :class:`ProjectLocation` as a string.""" @@ -1562,7 +1561,7 @@ def __repr__(self) -> str: from .label import Label from .project_membership import ProjectMembership from .proposal import Proposal -from .rsvp import Rsvp +from .rsvp import Rsvp, RsvpStateEnum from .session import Session from .sponsor_membership import ProjectSponsorMembership from .update import Update @@ -1641,3 +1640,7 @@ def __repr__(self) -> str: # joined model, not the first grants_via={Rsvp.participant: {'participant', 'project_participant'}}, ) + + +# Tail imports +from .comment import SET_TYPE, Commentset diff --git a/funnel/models/project_membership.py b/funnel/models/project_membership.py index 897b2e1cc..60a146bc6 100644 --- a/funnel/models/project_membership.py +++ b/funnel/models/project_membership.py @@ -7,7 +7,7 @@ from coaster.sqlalchemy import immutable, with_roles from . import Mapped, Model, declared_attr, relationship, sa, sa_orm -from .membership_mixin import ImmutableUserMembershipMixin +from .membership_mixin import ImmutableMembershipMixin from .project import Project __all__ = ['ProjectMembership', 'project_child_role_map', 'project_child_role_set'] @@ -35,7 +35,7 @@ ) -class ProjectMembership(ImmutableUserMembershipMixin, Model): +class ProjectMembership(ImmutableMembershipMixin, Model): """Users can be crew members of projects, with specified access rights.""" __tablename__ = 'project_membership' @@ -60,7 +60,7 @@ class ProjectMembership(ImmutableUserMembershipMixin, Model): }, 'project_crew': { 'read': { - 'record_type_label', + 'record_type_enum', 'granted_at', 'granted_by', 'revoked_at', @@ -104,7 +104,7 @@ class ProjectMembership(ImmutableUserMembershipMixin, Model): } project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False + sa.ForeignKey('project.id', ondelete='CASCADE'), default=None, nullable=False ) project: Mapped[Project] = with_roles( relationship(back_populates='crew_memberships'), @@ -117,35 +117,27 @@ class ProjectMembership(ImmutableUserMembershipMixin, Model): # 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( - sa.Boolean, nullable=False, default=False - ) + is_editor: Mapped[bool] = sa_orm.mapped_column(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( - sa.Boolean, nullable=False, default=False - ) + is_promoter: Mapped[bool] = sa_orm.mapped_column(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( - sa.Boolean, nullable=False, default=False - ) + is_usher: Mapped[bool] = sa_orm.mapped_column(default=False) #: Optional label, indicating the member's role in the project label: Mapped[str | None] = immutable( sa_orm.mapped_column( - sa.Unicode, sa.CheckConstraint( "label <> ''", name='project_crew_membership_label_check' - ), - nullable=True, + ) ) ) @declared_attr.directive @classmethod - def __table_args__(cls) -> tuple: + def __table_args__(cls) -> tuple: # type: ignore[override] """Table arguments.""" try: args = list(super().__table_args__) diff --git a/funnel/models/proposal.py b/funnel/models/proposal.py index 071c7fc54..ba1f8374c 100644 --- a/funnel/models/proposal.py +++ b/funnel/models/proposal.py @@ -33,7 +33,6 @@ sa_orm, ) from .account import Account -from .comment import SET_TYPE, Commentset from .helpers import ( MarkdownCompositeDocument, add_search_trigger, @@ -42,7 +41,7 @@ from .label import Label, ProposalLabelProxy, proposal_label from .project import Project from .project_membership import project_child_role_map -from .reorder_mixin import ReorderProtoMixin +from .reorder_mixin import ReorderMixin from .video_mixin import VideoMixin __all__ = ['PROPOSAL_STATE', 'Proposal', 'ProposalSuuidRedirect'] @@ -127,20 +126,18 @@ class PROPOSAL_STATE(LabeledEnum): # noqa: N801 # --- Models ------------------------------------------------------------------ -class Proposal( # type: ignore[misc] - UuidMixin, BaseScopedIdNameMixin, VideoMixin, ReorderProtoMixin, Model -): +class Proposal(UuidMixin, BaseScopedIdNameMixin, VideoMixin, ReorderMixin, Model): __tablename__ = 'proposal' created_by_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False + sa.ForeignKey('account.id'), default=None, nullable=False ) created_by: Mapped[Account] = with_roles( relationship(back_populates='created_proposals'), grants={'creator', 'participant'}, ) project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), nullable=False + sa.ForeignKey('project.id'), default=None, nullable=False ) project: Mapped[Project] = with_roles( relationship(back_populates='proposals'), @@ -165,8 +162,7 @@ class Proposal( # type: ignore[misc] _state: Mapped[int] = sa_orm.mapped_column( 'state', - sa.Integer, - StateManager.check_constraint('state', PROPOSAL_STATE), + StateManager.check_constraint('state', PROPOSAL_STATE, sa.Integer), default=PROPOSAL_STATE.SUBMITTED, nullable=False, ) @@ -175,7 +171,7 @@ class Proposal( # type: ignore[misc] ) commentset_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('commentset.id'), nullable=False + sa.ForeignKey('commentset.id'), default=None, nullable=False ) commentset: Mapped[Commentset] = relationship( uselist=False, lazy='joined', single_parent=True, back_populates='proposal' @@ -184,27 +180,17 @@ class Proposal( # type: ignore[misc] body, body_text, body_html = MarkdownCompositeDocument.create( 'body', nullable=False, default='' ) - description: Mapped[str] = sa_orm.mapped_column( - sa.Unicode, nullable=False, default='' - ) - custom_description: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) - template: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) - featured: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + description: Mapped[str] = sa_orm.mapped_column(default='') + custom_description: Mapped[bool] = sa_orm.mapped_column(default=False) + template: Mapped[bool] = sa_orm.mapped_column(default=False) + featured: Mapped[bool] = sa_orm.mapped_column(default=False) 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'} - ) + revisionid: Mapped[int] = with_roles(sa_orm.mapped_column(), read={'all'}) search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( @@ -245,7 +231,7 @@ class Proposal( # type: ignore[misc] relationship( primaryjoin=lambda: sa.and_( ProposalMembership.proposal_id == Proposal.id, - ProposalMembership.is_active, + ProposalMembership.is_active, # type: ignore[has-type] # FIXME ), order_by=lambda: ProposalMembership.seq, viewonly=True, @@ -267,7 +253,7 @@ class Proposal( # type: ignore[misc] lazy='dynamic', primaryjoin=lambda: sa.and_( ProposalSponsorMembership.proposal_id == Proposal.id, - ProposalSponsorMembership.is_active, + ProposalSponsorMembership.is_active, # type: ignore[has-type] # FIXME ), order_by=lambda: ProposalSponsorMembership.seq, viewonly=True, @@ -514,8 +500,9 @@ def first_user(self) -> Account: def move_to(self, project: Project) -> None: """Move to a new project and reset :attr:`url_id`.""" self.project = project - self.url_id = None # pylint: disable=attribute-defined-outside-init - self.make_id() + # pylint: disable=attribute-defined-outside-init + self.url_id = None + self.make_scoped_id() def update_description(self) -> None: if not self.custom_description: @@ -579,7 +566,7 @@ def get( # type: ignore[override] # pylint: disable=arguments-differ add_search_trigger(Proposal, 'search_vector') -class ProposalSuuidRedirect(BaseMixin, Model): +class ProposalSuuidRedirect(BaseMixin[int, Account], Model): """Holds Proposal SUUIDs from before when they were deprecated.""" __tablename__ = 'proposal_suuid_redirect' @@ -588,12 +575,13 @@ class ProposalSuuidRedirect(BaseMixin, Model): sa.Unicode(22), nullable=False, index=True ) proposal_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False + sa.ForeignKey('proposal.id', ondelete='CASCADE'), default=None, nullable=False ) proposal: Mapped[Proposal] = relationship() # Tail imports +from .comment import SET_TYPE, Commentset from .proposal_membership import ProposalMembership from .sponsor_membership import ProposalSponsorMembership diff --git a/funnel/models/proposal_membership.py b/funnel/models/proposal_membership.py index cfa141ce7..3d01df05b 100644 --- a/funnel/models/proposal_membership.py +++ b/funnel/models/proposal_membership.py @@ -9,22 +9,13 @@ from coaster.sqlalchemy import immutable, with_roles from . import Mapped, Model, relationship, sa, sa_orm -from .membership_mixin import ( - FrozenAttributionProtoMixin, - ImmutableUserMembershipMixin, - ReorderMembershipProtoMixin, -) +from .membership_mixin import FrozenAttributionMixin, ReorderMembershipMixin from .proposal import Proposal __all__ = ['ProposalMembership'] -class ProposalMembership( # type: ignore[misc] - ImmutableUserMembershipMixin, - FrozenAttributionProtoMixin, - ReorderMembershipProtoMixin, - Model, -): +class ProposalMembership(FrozenAttributionMixin, ReorderMembershipMixin, Model): """Users can be presenters or reviewers on proposals.""" __tablename__ = 'proposal_membership' @@ -78,8 +69,8 @@ class ProposalMembership( # type: ignore[misc] proposal_id: Mapped[int] = with_roles( sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), + default=None, nullable=False, ), read={'member', 'editor'}, @@ -97,16 +88,12 @@ class ProposalMembership( # type: ignore[misc] #: Uncredited members are not listed in the main display, but can edit and may be #: listed in a details section. Uncredited memberships are for support roles such #: as copy editors. - is_uncredited: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + is_uncredited: Mapped[bool] = sa_orm.mapped_column(default=False) #: Optional label, indicating the member's role on the proposal label: Mapped[str | None] = immutable( sa_orm.mapped_column( - sa.Unicode, - sa.CheckConstraint("label <> ''", name='proposal_membership_label_check'), - nullable=True, + sa.CheckConstraint("label <> ''", name='proposal_membership_label_check') ) ) diff --git a/funnel/models/reorder_mixin.py b/funnel/models/reorder_mixin.py index a77720ecc..055554cf6 100644 --- a/funnel/models/reorder_mixin.py +++ b/funnel/models/reorder_mixin.py @@ -2,42 +2,43 @@ from __future__ import annotations -from datetime import datetime -from typing import TYPE_CHECKING, ClassVar, TypeVar -from uuid import UUID +from typing import Any, Protocol, TypeVar -from . import Mapped, QueryProperty, db, declarative_mixin, sa, sa_orm +from . import Mapped, db, declarative_mixin, sa, sa_orm +from .typing import ModelIdProtocol -__all__ = ['ReorderProtoMixin'] +__all__ = ['ReorderMixin'] # Use of TypeVar for subclasses of ReorderMixin as defined in these mypy tickets: # https://github.com/python/mypy/issues/1212 # https://github.com/python/mypy/issues/7191 -Reorderable = TypeVar('Reorderable', bound='ReorderProtoMixin') + + +class ReorderSubclassProtocol(ModelIdProtocol, Protocol): + parent_id: Mapped[Any] + parent: Mapped[Any] + seq: Mapped[Any] + + @property + def parent_scoped_reorder_query_filter(self) -> sa.ColumnElement[bool]: + ... + + def reorder_item(self: Reorderable, other: Reorderable, before: bool) -> None: + ... + + +Reorderable = TypeVar('Reorderable', bound='ReorderSubclassProtocol') @declarative_mixin -class ReorderProtoMixin: +class ReorderMixin: """Adds support for re-ordering sequences within a parent container.""" - if TYPE_CHECKING: - #: Subclasses must have a created_at column - created_at: Mapped[datetime] - #: Subclass must have a primary key that is int or uuid - id: Mapped[int | UUID] # noqa: A001 - #: Subclass must declare a parent_id synonym to the parent model fkey column - parent_id: Mapped[int | UUID] - #: Subclass must declare a seq column or synonym, holding a sequence id. It - #: need not be unique, but reordering is meaningless when both items have the - #: same number - seq: Mapped[int] - - #: Subclass must offer a SQLAlchemy query (this is standard from base classes) - query: ClassVar[QueryProperty] - @property - def parent_scoped_reorder_query_filter(self: Reorderable) -> sa.ColumnElement[bool]: + def parent_scoped_reorder_query_filter( + self: ReorderSubclassProtocol, + ) -> sa.ColumnElement[bool]: """ Return a query filter that includes a scope limitation to the parent. @@ -83,13 +84,13 @@ def reorder_item(self: Reorderable, other: Reorderable, before: bool) -> None: ) .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() ) # Pop-off items that share a sequence number and don't need to be moved - while items_to_reorder[0].id != self.id: + while items_to_reorder[0].id_ != self.id_: items_to_reorder.pop(0) # Reordering! Move down the list (reversed if `before`), reassigning numbers. @@ -101,7 +102,7 @@ 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 = ( + self.seq: Mapped[int] = ( sa.select(sa.func.coalesce(sa.func.max(cls.seq) + 1, 1)) .where(self.parent_scoped_reorder_query_filter) .scalar_subquery() @@ -115,7 +116,7 @@ def reorder_item(self: Reorderable, other: Reorderable, before: bool) -> None: # of SQLAlchemy 2.0.x. Should that behaviour change, a switch to # bulk_update_mappings will be required db.session.flush() - if reorderable_item.id == other.id: + if reorderable_item.id_ == other.id_: # Don't bother reordering anything after `other` break # Assign other's previous sequence number to self diff --git a/funnel/models/rsvp.py b/funnel/models/rsvp.py index 16fa3181d..e8a9394e9 100644 --- a/funnel/models/rsvp.py +++ b/funnel/models/rsvp.py @@ -2,20 +2,33 @@ from __future__ import annotations -from typing import Literal, Self, overload +from dataclasses import dataclass +from enum import ReprEnum +from typing import TYPE_CHECKING, Any, Literal, Self, overload from flask import current_app from baseframe import __ from coaster.sqlalchemy import StateManager, with_roles -from coaster.utils import LabeledEnum - -from . import Mapped, Model, NoIdMixin, UuidMixin, db, relationship, sa, sa_orm, types +from coaster.utils import DataclassFromType, LabeledEnum + +from . import ( + Mapped, + Model, + NoIdMixin, + UuidMixin, + db, + declared_attr, + relationship, + sa, + sa_orm, + types, +) from .account import Account, AccountEmail, AccountEmailClaim, AccountPhone from .project import Project from .project_membership import project_child_role_map -__all__ = ['Rsvp', 'RSVP_STATUS'] +__all__ = ['RSVP_STATUS', 'RsvpStateEnum', 'Rsvp'] class RSVP_STATUS(LabeledEnum): # noqa: N801 @@ -27,10 +40,26 @@ class RSVP_STATUS(LabeledEnum): # noqa: N801 AWAITING = ('A', 'awaiting', __("Awaiting")) +@dataclass(frozen=True) +class _RsvpOptions(DataclassFromType, str): + """RSVP options.""" + + # The empty default is required for Mypy's enum plugin's `Enum.__call__` analysis + response: str = '' + label: str = '' + + +class RsvpStateEnum(_RsvpOptions, ReprEnum): + YES = 'Y', __("Yes"), __("Going") + NO = 'N', __("No"), __("Not going") + MAYBE = 'M', __("Maybe"), __("Maybe") + AWAITING = 'A', __("Invite"), __("Awaiting") + + class Rsvp(UuidMixin, NoIdMixin, Model): __tablename__ = 'rsvp' project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), nullable=False, primary_key=True + sa.ForeignKey('project.id'), default=None, nullable=False, primary_key=True ) project: Mapped[Project] = with_roles( relationship(back_populates='rsvps'), @@ -39,7 +68,7 @@ class Rsvp(UuidMixin, NoIdMixin, Model): datasets={'primary'}, ) participant_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False, primary_key=True + sa.ForeignKey('account.id'), default=None, nullable=False, primary_key=True ) participant: Mapped[Account] = with_roles( relationship(back_populates='rsvps'), @@ -57,8 +86,8 @@ class Rsvp(UuidMixin, NoIdMixin, Model): _state: Mapped[str] = sa_orm.mapped_column( 'state', sa.CHAR(1), - StateManager.check_constraint('state', RSVP_STATUS), - default=RSVP_STATUS.AWAITING, + StateManager.check_constraint('state', RsvpStateEnum, sa.CHAR(1)), + default=RsvpStateEnum.AWAITING, nullable=False, ) state = with_roles( @@ -66,6 +95,9 @@ class Rsvp(UuidMixin, NoIdMixin, Model): call={'owner', 'project_promoter'}, ) + if TYPE_CHECKING: + id_: declared_attr[Any] # Fake entry for compatibility with ModelUuidProtocol + __roles__ = { 'owner': {'read': {'created_at', 'updated_at'}}, 'project_promoter': {'read': {'created_at', 'updated_at'}}, @@ -179,7 +211,7 @@ def get_for( cls, project: Project, account: Account | None, create: bool = False ) -> Self | None: if account is not None: - result = cls.query.get((project.id, account.id)) + result = db.session.get(cls, (project.id, account.id)) if not result and create: result = cls(project=project, participant=account) db.session.add(result) diff --git a/funnel/models/saved.py b/funnel/models/saved.py index 4fb250b80..4595b3060 100644 --- a/funnel/models/saved.py +++ b/funnel/models/saved.py @@ -19,6 +19,7 @@ class SavedProject(NoIdMixin, Model): #: User account that saved this project account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id', ondelete='CASCADE'), + default=None, nullable=False, primary_key=True, ) @@ -27,8 +28,8 @@ class SavedProject(NoIdMixin, Model): ) #: Project that was saved project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), + default=None, nullable=False, primary_key=True, index=True, @@ -36,7 +37,10 @@ class SavedProject(NoIdMixin, Model): project: Mapped[Project] = relationship(back_populates='saves') #: Timestamp when the save happened saved_at: Mapped[datetime] = sa_orm.mapped_column( - sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() + sa.TIMESTAMP(timezone=True), + nullable=False, + insert_default=sa.func.utcnow(), + default=None, ) #: User's plaintext note to self on why they saved this (optional) description: Mapped[str | None] = sa_orm.mapped_column( @@ -60,14 +64,15 @@ class SavedSession(NoIdMixin, Model): #: User account that saved this session account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id', ondelete='CASCADE'), + default=None, nullable=False, primary_key=True, ) account: Mapped[Account] = relationship(back_populates='saved_sessions') #: Session that was saved session_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('session.id', ondelete='CASCADE'), + default=None, nullable=False, primary_key=True, index=True, @@ -75,7 +80,10 @@ class SavedSession(NoIdMixin, Model): session: Mapped[Session] = relationship(back_populates='saves') #: Timestamp when the save happened saved_at: Mapped[datetime] = sa_orm.mapped_column( - sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() + sa.TIMESTAMP(timezone=True), + nullable=False, + insert_default=sa.func.utcnow(), + default=None, ) #: User's plaintext note to self on why they saved this (optional) description: Mapped[str | None] = sa_orm.mapped_column( diff --git a/funnel/models/session.py b/funnel/models/session.py index 60013938d..6ed90e24c 100644 --- a/funnel/models/session.py +++ b/funnel/models/session.py @@ -39,11 +39,11 @@ __all__ = ['Session'] -class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): +class Session(UuidMixin, BaseScopedIdNameMixin[int, Account], VideoMixin, Model): __tablename__ = 'session' project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), nullable=False + sa.ForeignKey('project.id'), default=None, nullable=False ) project: Mapped[Project] = with_roles( relationship(back_populates='sessions'), @@ -54,7 +54,7 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): 'description', default='', nullable=False ) proposal_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('proposal.id'), nullable=True, unique=True + sa.ForeignKey('proposal.id'), default=None, nullable=True, unique=True ) proposal: Mapped[Proposal | None] = relationship(back_populates='session') speaker: Mapped[str | None] = sa_orm.mapped_column( @@ -67,26 +67,18 @@ class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, Model): sa.TIMESTAMP(timezone=True), nullable=True, index=True ) venue_room_id: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('venue_room.id'), nullable=True + sa.ForeignKey('venue_room.id'), default=None, nullable=True ) 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( - sa.Boolean, default=False, nullable=False - ) - is_restricted_video: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, default=False, nullable=False - ) + is_break: Mapped[bool] = sa_orm.mapped_column(default=False) + featured: Mapped[bool] = sa_orm.mapped_column(default=False) + is_restricted_video: Mapped[bool] = sa_orm.mapped_column(default=False) banner_image_url: Mapped[str | None] = sa_orm.mapped_column( 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'} - ) + revisionid: Mapped[int] = with_roles(sa_orm.mapped_column(), read={'all'}) search_vector: Mapped[str] = sa_orm.mapped_column( TSVectorType( @@ -231,7 +223,7 @@ def _scheduled_expression(cls) -> sa.ColumnElement[bool]: def start_at_localized(self) -> datetime | None: return ( localize_timezone(self.start_at, tz=self.project.timezone) - if self.start_at + if self.start_at is not None else None ) @@ -239,7 +231,7 @@ def start_at_localized(self) -> datetime | None: def end_at_localized(self) -> datetime | None: return ( localize_timezone(self.end_at, tz=self.project.timezone) - if self.end_at + if self.end_at is not None else None ) diff --git a/funnel/models/shortlink.py b/funnel/models/shortlink.py index 3b8c1b918..d44b3e343 100644 --- a/funnel/models/shortlink.py +++ b/funnel/models/shortlink.py @@ -222,15 +222,13 @@ class Shortlink(NoIdMixin, Model): ) #: Id of account that created this shortlink (optional) created_by_id: Mapped[int | None] = sa_orm.mapped_column( - sa.ForeignKey('account.id', ondelete='SET NULL'), nullable=True + sa.ForeignKey('account.id', ondelete='SET NULL'), default=None, nullable=True ) #: Account that created this shortlink (optional) created_by: Mapped[Account | None] = relationship() #: Is this link enabled? If not, render 410 Gone - enabled: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=True - ) + enabled: Mapped[bool] = sa_orm.mapped_column(default=True) @hybrid_property def name(self) -> str: diff --git a/funnel/models/site_membership.py b/funnel/models/site_membership.py index f8576ff64..7d8eb6663 100644 --- a/funnel/models/site_membership.py +++ b/funnel/models/site_membership.py @@ -5,12 +5,12 @@ from werkzeug.utils import cached_property from . import Mapped, Model, declared_attr, sa, sa_orm -from .membership_mixin import ImmutableUserMembershipMixin +from .membership_mixin import ImmutableMembershipMixin __all__ = ['SiteMembership'] -class SiteMembership(ImmutableUserMembershipMixin, Model): +class SiteMembership(ImmutableMembershipMixin, Model): """Membership roles for users who are site administrators.""" __tablename__ = 'site_membership' @@ -38,36 +38,29 @@ class SiteMembership(ImmutableUserMembershipMixin, Model): #: SiteMembership doesn't have a container limiting its scope parent_id = None - parent_id_column = None + parent_id_column = '' # Must be of type str, not None parent = None # Site admin roles (at least one must be True): #: Comment moderators can delete comments - is_comment_moderator: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + is_comment_moderator: Mapped[bool] = sa_orm.mapped_column(default=False) #: User moderators can suspend users - is_user_moderator: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + is_user_moderator: Mapped[bool] = sa_orm.mapped_column(default=False) #: Site editors can feature or reject projects - is_site_editor: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + is_site_editor: Mapped[bool] = sa_orm.mapped_column(default=False) #: Sysadmins can manage technical settings - is_sysadmin: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, nullable=False, default=False - ) + is_sysadmin: Mapped[bool] = sa_orm.mapped_column(default=False) @declared_attr.directive @classmethod - def __table_args__(cls) -> tuple: + def __table_args__(cls) -> tuple: # type: ignore[override] """Table arguments.""" try: args = list(super().__table_args__) except AttributeError: args = [] + kwargs = args.pop(-1) if args and isinstance(args[-1], dict) else None args.append( sa.CheckConstraint( sa.or_( @@ -79,6 +72,8 @@ def __table_args__(cls) -> tuple: name='site_membership_has_role', ) ) + if kwargs: + args.append(kwargs) return tuple(args) def __repr__(self) -> str: diff --git a/funnel/models/sponsor_membership.py b/funnel/models/sponsor_membership.py index 829d7186f..563d3fa81 100644 --- a/funnel/models/sponsor_membership.py +++ b/funnel/models/sponsor_membership.py @@ -9,23 +9,14 @@ from coaster.sqlalchemy import immutable from . import Mapped, Model, relationship, sa, sa_orm -from .membership_mixin import ( - FrozenAttributionProtoMixin, - ImmutableUserMembershipMixin, - ReorderMembershipProtoMixin, -) +from .membership_mixin import FrozenAttributionMixin, ReorderMembershipMixin from .project import Project from .proposal import Proposal __all__ = ['ProjectSponsorMembership', 'ProposalSponsorMembership'] -class ProjectSponsorMembership( # type: ignore[misc] - ImmutableUserMembershipMixin, - FrozenAttributionProtoMixin, - ReorderMembershipProtoMixin, - Model, -): +class ProjectSponsorMembership(FrozenAttributionMixin, ReorderMembershipMixin, Model): """Sponsor of a project.""" __tablename__ = 'project_sponsor_membership' @@ -83,7 +74,7 @@ class ProjectSponsorMembership( # type: ignore[misc] revoke_on_member_delete: ClassVar[bool] = False project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id', ondelete='CASCADE'), nullable=False + sa.ForeignKey('project.id', ondelete='CASCADE'), default=None, nullable=False ) project: Mapped[Project] = relationship(back_populates='all_sponsor_memberships') parent_id: Mapped[int] = sa_orm.synonym('project_id') @@ -92,18 +83,14 @@ class ProjectSponsorMembership( # type: ignore[misc] #: Is this sponsor being promoted for commercial reasons? Projects may have a legal #: obligation to reveal this. This column records a declaration from the project. - is_promoted: Mapped[bool] = immutable( - sa_orm.mapped_column(sa.Boolean, nullable=False) - ) + is_promoted: Mapped[bool] = immutable(sa_orm.mapped_column()) #: Optional label, indicating the type of sponsor label: Mapped[str | None] = immutable( sa_orm.mapped_column( - sa.Unicode, sa.CheckConstraint( "label <> ''", name='project_sponsor_membership_label_check' - ), - nullable=True, + ) ) ) @@ -120,12 +107,7 @@ def offered_roles(self) -> set[str]: # FIXME: Replace this with existing proposal collaborator as they're now both related # to "account" -class ProposalSponsorMembership( # type: ignore[misc] - FrozenAttributionProtoMixin, - ReorderMembershipProtoMixin, - ImmutableUserMembershipMixin, - Model, -): +class ProposalSponsorMembership(FrozenAttributionMixin, ReorderMembershipMixin, Model): """Sponsor of a proposal.""" __tablename__ = 'proposal_sponsor_membership' @@ -183,7 +165,7 @@ class ProposalSponsorMembership( # type: ignore[misc] revoke_on_member_delete: ClassVar[bool] = False proposal_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('proposal.id', ondelete='CASCADE'), nullable=False + sa.ForeignKey('proposal.id', ondelete='CASCADE'), default=None, nullable=False ) proposal: Mapped[Proposal] = relationship(back_populates='all_sponsor_memberships') parent_id: Mapped[int] = sa_orm.synonym('proposal_id') @@ -192,18 +174,14 @@ class ProposalSponsorMembership( # type: ignore[misc] #: Is this sponsor being promoted for commercial reasons? Proposals may have a legal #: obligation to reveal this. This column records a declaration from the proposal. - is_promoted: Mapped[bool] = immutable( - sa_orm.mapped_column(sa.Boolean, nullable=False) - ) + is_promoted: Mapped[bool] = immutable(sa_orm.mapped_column()) #: Optional label, indicating the type of sponsor label: Mapped[str | None] = immutable( sa_orm.mapped_column( - sa.Unicode, sa.CheckConstraint( "label <> ''", name='proposal_sponsor_membership_label_check' - ), - nullable=True, + ) ) ) diff --git a/funnel/models/sync_ticket.py b/funnel/models/sync_ticket.py index 19969fed4..d2f31eb99 100644 --- a/funnel/models/sync_ticket.py +++ b/funnel/models/sync_ticket.py @@ -112,7 +112,7 @@ class TicketEvent(GetTitleMixin, Model): __tablename__ = 'ticket_event' project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), nullable=False + sa.ForeignKey('project.id'), default=None, nullable=False ) project: Mapped[Project] = with_roles( relationship(back_populates='ticket_events'), @@ -171,7 +171,7 @@ class TicketType(GetTitleMixin, Model): __tablename__ = 'ticket_type' project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), nullable=False + sa.ForeignKey('project.id'), default=None, nullable=False ) project: Mapped[Project] = with_roles( relationship(back_populates='ticket_types'), @@ -202,7 +202,9 @@ class TicketType(GetTitleMixin, Model): } -class TicketParticipant(OptionalEmailAddressMixin, UuidMixin, BaseMixin, Model): +class TicketParticipant( + OptionalEmailAddressMixin, UuidMixin, BaseMixin[int, Account], Model +): """A participant in one or more events, synced from an external ticket source.""" __tablename__ = 'ticket_participant' @@ -239,22 +241,28 @@ class TicketParticipant(OptionalEmailAddressMixin, UuidMixin, BaseMixin, Model): ) # public key puk: Mapped[str] = sa_orm.mapped_column( - sa.Unicode(44), nullable=False, default=make_public_key, unique=True + sa.Unicode(44), + nullable=False, + insert_default=make_public_key, + default=None, + unique=True, ) 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( - sa.Boolean, default=False, nullable=False + sa.Unicode(44), + nullable=False, + insert_default=make_private_key, + default=None, + unique=True, ) + badge_printed: Mapped[bool] = sa_orm.mapped_column(default=False) participant_id: Mapped[int | None] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=True + sa.ForeignKey('account.id'), default=None, nullable=True ) participant: Mapped[Account | None] = relationship( back_populates='ticket_participants' ) project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), nullable=False + sa.ForeignKey('project.id'), default=None, nullable=False ) project: Mapped[Project] = with_roles( relationship(Project, back_populates='ticket_participants'), @@ -294,7 +302,7 @@ def roles_for( if actor is not None: if actor == self.participant: roles.add('member') - cx = ContactExchange.query.get((actor.id, self.id)) + cx = db.session.get(ContactExchange, (actor.id, self.id)) if cx is not None: roles.add('scanner') return roles @@ -403,28 +411,26 @@ def checkin_list(cls, ticket_event: TicketEvent) -> list: # TODO: List type? return query.all() -class TicketEventParticipant(BaseMixin, Model): +class TicketEventParticipant(BaseMixin[int, Account], Model): """Join model between :class:`TicketParticipant` and :class:`TicketEvent`.""" __tablename__ = 'ticket_event_participant' ticket_participant_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('ticket_participant.id'), nullable=False + sa.ForeignKey('ticket_participant.id'), default=None, nullable=False ) ticket_participant: Mapped[TicketParticipant] = relationship( back_populates='ticket_event_participants', overlaps='ticket_events,ticket_participants', ) ticket_event_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('ticket_event.id'), nullable=False + sa.ForeignKey('ticket_event.id'), default=None, nullable=False ) ticket_event: Mapped[TicketEvent] = relationship( back_populates='ticket_event_participants', overlaps='ticket_events,ticket_participants', ) - checked_in: Mapped[bool] = sa_orm.mapped_column( - sa.Boolean, default=False, nullable=False - ) + checked_in: Mapped[bool] = sa_orm.mapped_column(default=False) __table_args__ = ( # Uses a custom name that is not as per convention because the default name is @@ -450,7 +456,7 @@ def get( ) -class TicketClient(BaseMixin, Model): +class TicketClient(BaseMixin[int, Account], Model): __tablename__ = 'ticket_client' name: Mapped[str] = with_roles( sa_orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} @@ -468,7 +474,7 @@ class TicketClient(BaseMixin, Model): sa_orm.mapped_column(sa.Unicode(80), nullable=False), rw={'project_promoter'} ) project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), nullable=False + sa.ForeignKey('project.id'), default=None, nullable=False ) project: Mapped[Project] = with_roles( relationship(back_populates='ticket_clients'), @@ -523,7 +529,7 @@ def import_from_list(self, ticket_list): ticket.ticket_participant.add_events(ticket_type.ticket_events) -class SyncTicket(BaseMixin, Model): +class SyncTicket(BaseMixin[int, Account], Model): """Model for a ticket that was bought elsewhere, like Boxoffice or Explara.""" __tablename__ = 'sync_ticket' @@ -531,17 +537,17 @@ class SyncTicket(BaseMixin, Model): 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 + sa.ForeignKey('ticket_type.id'), default=None, nullable=False ) 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 + sa.ForeignKey('ticket_participant.id'), default=None, nullable=False ) ticket_participant: Mapped[TicketParticipant] = relationship( back_populates='sync_tickets' ) ticket_client_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('ticket_client.id'), nullable=False + sa.ForeignKey('ticket_client.id'), default=None, nullable=False ) ticket_client: Mapped[TicketClient] = relationship(back_populates='sync_tickets') __table_args__ = (sa.UniqueConstraint('ticket_client_id', 'order_no', 'ticket_no'),) diff --git a/funnel/models/typing.py b/funnel/models/typing.py index 694350b24..ca09f3d1a 100644 --- a/funnel/models/typing.py +++ b/funnel/models/typing.py @@ -1,49 +1,95 @@ """Union types for models with shared functionality.""" -from typing import Union - -from .account import Account, AccountOldId, Team -from .auth_client import AuthClient -from .comment import Comment, Commentset -from .label import Label -from .login_session import LoginSession -from .membership_mixin import ImmutableMembershipMixin -from .moderation import CommentModeratorReport -from .project import Project -from .proposal import Proposal -from .rsvp import Rsvp -from .session import Session -from .sync_ticket import TicketParticipant -from .update import Update -from .venue import Venue, VenueRoom - -__all__ = ['UuidModelUnion', 'SearchModelUnion', 'MarkdownModelUnion'] - -# All models with a `uuid` attr -UuidModelUnion = Union[ - Account, - AccountOldId, - AuthClient, - Comment, - CommentModeratorReport, - Commentset, - ImmutableMembershipMixin, - LoginSession, - Project, - Proposal, - Rsvp, - Session, - Team, - TicketParticipant, - Update, - Venue, - VenueRoom, -] +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Sequence +from datetime import datetime +from typing import Any, ClassVar, Literal, Protocol, overload, runtime_checkable +from uuid import UUID -# All models with a `search_vector` attr -SearchModelUnion = Union[Account, Comment, Label, Project, Proposal, Session, Update] +from sqlalchemy import Table +from sqlalchemy.orm import Mapped, declared_attr -# All models with one or more markdown composite columns -MarkdownModelUnion = Union[ - Account, Comment, Project, Proposal, Session, Update, Venue, VenueRoom +from coaster.sqlalchemy import LazyRoleSet, QueryProperty +from coaster.utils import InspectableSet + +__all__ = [ + 'ModelProtocol', + 'ModelTimestampProtocol', + 'ModelUrlProtocol', + 'ModelRoleProtocol', + 'ModelIdProtocol', + 'ModelUuidProtocol', + 'ModelSearchProtocol', ] + + +class ModelProtocol(Protocol): + __tablename__: str + __table__: ClassVar[Table] + query: ClassVar[QueryProperty] + + +class ModelTimestampProtocol(ModelProtocol, Protocol): + created_at: declared_attr[datetime] + updated_at: declared_attr[datetime] + + +class ModelUrlProtocol(Protocol): + @property + def absolute_url(self) -> str | None: + ... + + def url_for(self, action: str = 'view', **kwargs) -> str: + ... + + +class ModelRoleProtocol(Protocol): + def roles_for( + self, actor: Account | None = None, anchors: Sequence[Any] = () + ) -> LazyRoleSet: + ... + + @property + def current_roles(self) -> InspectableSet[LazyRoleSet]: + ... + + @overload + def actors_with( + self, roles: Iterable[str], with_role: Literal[False] = False + ) -> Iterator[Account]: + ... + + @overload + def actors_with( + self, roles: Iterable[str], with_role: Literal[True] + ) -> Iterator[tuple[Account, str]]: + ... + + def actors_with( + self, roles: Iterable[str], with_role: bool = False + ) -> Iterator[Account | tuple[Account, str]]: + ... + + +class ModelIdProtocol( + ModelTimestampProtocol, ModelUrlProtocol, ModelRoleProtocol, Protocol +): + id_: declared_attr[Any] + + +@runtime_checkable # FIXME: This is never used, but needed to make type checkers happy +class ModelUuidProtocol(ModelIdProtocol, Protocol): + uuid: declared_attr[UUID] + + +class ModelSearchProtocol(ModelUuidProtocol, Protocol): + search_vector: Mapped[str] + + @property + def title(self) -> Mapped[str] | declared_attr[str]: + ... + + +# Tail imports +from .account import Account diff --git a/funnel/models/update.py b/funnel/models/update.py index 266dccc34..7aca005c5 100644 --- a/funnel/models/update.py +++ b/funnel/models/update.py @@ -45,13 +45,15 @@ class VISIBILITY_STATE(LabeledEnum): # noqa: N801 RESTRICTED = (2, 'restricted', __("Restricted")) -class Update(UuidMixin, BaseScopedIdNameMixin, Model): +class Update(UuidMixin, BaseScopedIdNameMixin[int, Account], Model): __tablename__ = 'update' _visibility_state: Mapped[int] = sa_orm.mapped_column( 'visibility_state', sa.SmallInteger, - StateManager.check_constraint('visibility_state', VISIBILITY_STATE), + StateManager.check_constraint( + 'visibility_state', VISIBILITY_STATE, sa.SmallInteger + ), default=VISIBILITY_STATE.PUBLIC, nullable=False, index=True, @@ -63,7 +65,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): _state: Mapped[int] = sa_orm.mapped_column( 'state', sa.SmallInteger, - StateManager.check_constraint('state', UPDATE_STATE), + StateManager.check_constraint('state', UPDATE_STATE, sa.SmallInteger), default=UPDATE_STATE.DRAFT, nullable=False, index=True, @@ -71,7 +73,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): state = StateManager['Update']('_state', UPDATE_STATE, doc="Update state") created_by_id: Mapped[int] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=False, index=True + sa.ForeignKey('account.id'), default=None, nullable=False, index=True ) created_by: Mapped[Account] = with_roles( relationship( @@ -83,7 +85,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): ) project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), nullable=False, index=True + sa.ForeignKey('project.id'), default=None, nullable=False, index=True ) project: Mapped[Project] = with_roles( relationship(back_populates='updates'), @@ -123,17 +125,17 @@ 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(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(default=False), read={'all'} ) published_by_id: Mapped[int | None] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=True, index=True + sa.ForeignKey('account.id'), default=None, nullable=True, index=True ) published_by: Mapped[Account | None] = with_roles( relationship( @@ -147,7 +149,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): ) deleted_by_id: Mapped[int | None] = sa_orm.mapped_column( - sa.ForeignKey('account.id'), nullable=True, index=True + sa.ForeignKey('account.id'), default=None, nullable=True, index=True ) deleted_by: Mapped[Account | None] = with_roles( relationship(back_populates='deleted_updates', foreign_keys=[deleted_by_id]), @@ -163,7 +165,7 @@ class Update(UuidMixin, BaseScopedIdNameMixin, Model): ) commentset_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('commentset.id'), nullable=False + sa.ForeignKey('commentset.id'), default=None, nullable=False ) commentset: Mapped[Commentset] = with_roles( relationship( diff --git a/funnel/models/utils.py b/funnel/models/utils.py index 3d7fa93f3..83579f3c7 100644 --- a/funnel/models/utils.py +++ b/funnel/models/utils.py @@ -2,13 +2,14 @@ from __future__ import annotations -from typing import Literal, NamedTuple, overload +from typing import Literal, NamedTuple, TypeVar, overload import phonenumbers from sqlalchemy import PrimaryKeyConstraint, UniqueConstraint from .. import app from ..typing import OptionalMigratedTables +from . import Model, db from .account import ( Account, AccountEmail, @@ -16,10 +17,9 @@ AccountExternalId, AccountPhone, Anchor, - Model, - db, ) from .phone_number import PHONE_LOOKUP_REGIONS +from .typing import ModelIdProtocol __all__ = [ 'IncompleteUserMigrationError', @@ -89,7 +89,7 @@ def getuser(name: str, anchor: bool = False) -> Account | AccountAndAnchor | Non return AccountAndAnchor(None, None) return None else: - # If it wasn't an email address or an @username, check if it's a phone number + # If it was not an email address or an @username, check if it's a phone number try: # Assume unprefixed numbers to be a local number in one of our supported # regions, in order of priority. Also see @@ -107,9 +107,9 @@ def getuser(name: str, anchor: bool = False) -> Account | AccountAndAnchor | Non if anchor: return AccountAndAnchor(accountphone.account, accountphone) return accountphone.account - # No matching accountphone? Continue to trying as a username + # No matching account phone? Continue to trying as a username except phonenumbers.NumberParseException: - # This was not a parseable phone number. Continue to trying as a username + # This was not a parsable phone number. Continue to trying as a username pass # Last guess: username @@ -137,8 +137,15 @@ def getextid(service: str, userid: str) -> AccountExternalId | None: return AccountExternalId.get(service=service, userid=userid) -def merge_accounts(current_account: Account, other_account: Account) -> Account | None: +_A1 = TypeVar('_A1', bound=Account) +_A2 = TypeVar('_A2', bound=Account) + + +def merge_accounts(current_account: _A1, other_account: _A2) -> _A1 | _A2 | None: """Merge two user accounts and return the new user account.""" + keep_account: _A1 | _A2 + merge_account: _A1 | _A2 + app.logger.info( "Preparing to merge accounts %s and %s", current_account, other_account ) @@ -156,7 +163,7 @@ def merge_accounts(current_account: Account, other_account: Account) -> Account # keep_account. safe = do_migrate_instances(merge_account, keep_account, 'migrate_account') if safe: - # 2. Add merge_account's uuid to oldids and mark account as merged + # 2. Add merge_account's uuid to `oldids` and mark account as merged merge_account.mark_merged_into(keep_account) # 3. Transfer name and password if required if not keep_account.name: @@ -188,8 +195,8 @@ def merge_accounts(current_account: Account, other_account: Account) -> Account def do_migrate_instances( - old_instance: Model, - new_instance: Model, + old_instance: ModelIdProtocol, + new_instance: ModelIdProtocol, helper_method: str | None = None, ) -> bool: """ @@ -270,8 +277,8 @@ def do_migrate_table(table): for column in target_columns: session.execute( table.update() - .where(column == old_instance.id) - .values(**{column.name: new_instance.id}) + .where(column == old_instance.id_) + .values(**{column.name: new_instance.id_}) ) session.flush() diff --git a/funnel/models/venue.py b/funnel/models/venue.py index b57450922..e3791a19f 100644 --- a/funnel/models/venue.py +++ b/funnel/models/venue.py @@ -16,6 +16,7 @@ sa, sa_orm, ) +from .account import Account from .helpers import MarkdownCompositeBasic from .project import Project from .project_membership import project_child_role_map, project_child_role_set @@ -23,11 +24,11 @@ __all__ = ['Venue', 'VenueRoom'] -class Venue(UuidMixin, BaseScopedNameMixin, CoordinatesMixin, Model): +class Venue(UuidMixin, BaseScopedNameMixin[int, Account], CoordinatesMixin, Model): __tablename__ = 'venue' project_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('project.id'), nullable=False + sa.ForeignKey('project.id'), default=None, nullable=False ) project: Mapped[Project] = with_roles( relationship(back_populates='venues'), grants_via={None: project_child_role_map} @@ -59,7 +60,7 @@ class Venue(UuidMixin, BaseScopedNameMixin, CoordinatesMixin, Model): back_populates='venue', ) - seq: Mapped[int] = sa_orm.mapped_column(sa.Integer, nullable=False) + seq: Mapped[int] = sa_orm.mapped_column() __table_args__ = (sa.UniqueConstraint('project_id', 'name'),) @@ -110,11 +111,11 @@ class Venue(UuidMixin, BaseScopedNameMixin, CoordinatesMixin, Model): } -class VenueRoom(UuidMixin, BaseScopedNameMixin, Model): +class VenueRoom(UuidMixin, BaseScopedNameMixin[int, Account], Model): __tablename__ = 'venue_room' venue_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('venue.id'), nullable=False + sa.ForeignKey('venue.id'), default=None, nullable=False ) venue: Mapped[Venue] = with_roles( relationship(back_populates='rooms'), @@ -129,12 +130,13 @@ class VenueRoom(UuidMixin, BaseScopedNameMixin, Model): 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() 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 + Session.venue_room_id == VenueRoom.id, + Session.scheduled, # type: ignore[has-type] # FIXME ), viewonly=True, ) diff --git a/funnel/registry.py b/funnel/registry.py index fe75600ea..61951d7d4 100644 --- a/funnel/registry.py +++ b/funnel/registry.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Collection from dataclasses import dataclass from functools import wraps -from typing import Any, NoReturn +from typing import Any, NoReturn, cast from flask import Response, abort, jsonify, request from werkzeug.datastructures import MultiDict @@ -16,7 +16,7 @@ from baseframe.signals import exception_catchall from .models import AccountExternalId, AuthToken -from .typing import P, ReturnResponse +from .typing import ReturnResponse # Bearer token, as per # http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-15#section-2.1 @@ -32,7 +32,9 @@ def resource( description: str | None = None, trusted: bool = False, scope: str | None = None, - ) -> Callable[[Callable[P, Any]], Callable[[], ReturnResponse]]: + ) -> Callable[ + [Callable[[AuthToken, MultiDict, MultiDict], Any]], Callable[[], ReturnResponse] + ]: """ Decorate a resource function. @@ -137,7 +139,7 @@ def wrapper() -> ReturnResponse: 'trusted': trusted, 'f': f, } - return wrapper + return cast(Callable[[], ReturnResponse], wrapper) return decorator diff --git a/funnel/transports/email/send.py b/funnel/transports/email/send.py index 14e38a2e0..c83e0b542 100644 --- a/funnel/transports/email/send.py +++ b/funnel/transports/email/send.py @@ -5,7 +5,7 @@ import smtplib from dataclasses import dataclass from email.utils import formataddr, getaddresses, make_msgid, parseaddr -from typing import Optional, Union +from typing import Union from flask import current_app from flask_mailman import EmailMultiAlternatives @@ -30,14 +30,14 @@ ] # Email recipient type -EmailRecipient = Union[Account, tuple[Optional[str], str], str] +EmailRecipient = Union[Account, tuple[str | None, str], str] @dataclass class EmailAttachment: """An email attachment. Must have content, filename and mimetype.""" - content: str + content: bytes filename: str mimetype: str @@ -74,7 +74,6 @@ def jsonld_confirm_action(description: str, url: str, title: str) -> dict[str, o def jsonld_event_reservation(rsvp: Rsvp) -> dict[str, object]: """Schema.org JSON-LD markup for an event reservation.""" location: str | dict[str, object] - event_mode: str venue = rsvp.project.primary_venue if venue is not None: location = { diff --git a/funnel/utils/misc.py b/funnel/utils/misc.py index 99a3b36fd..11388698c 100644 --- a/funnel/utils/misc.py +++ b/funnel/utils/misc.py @@ -143,7 +143,7 @@ def extract_twitter_handle(handle: str) -> str | None: ) -def format_twitter_handle(handle: str) -> str: +def format_twitter_handle(handle: str | None) -> str: """Format twitter handle as an @ mention.""" return f"@{handle}" if handle else "" diff --git a/funnel/views/account.py b/funnel/views/account.py index 3890faf5d..ddb2e691c 100644 --- a/funnel/views/account.py +++ b/funnel/views/account.py @@ -21,11 +21,11 @@ from baseframe import _, forms from baseframe.forms import render_delete_sqla, render_form, render_message -from coaster.auth import current_auth from coaster.sqlalchemy import RoleAccessProxy from coaster.views import ClassView, get_next_url, render_with, route from .. import app +from ..auth import current_auth from ..forms import ( AccountDeleteForm, AccountForm, @@ -106,9 +106,8 @@ def user_locale(obj: Account) -> str: @Account.views('timezone') def user_timezone(obj: Account) -> str: """Human-friendly identifier for user's timezone, defaulting to timezone name.""" - return timezone_identifiers.get( - str(obj.timezone) if obj.timezone else '', obj.timezone - ) + timezone = str(u_tz) if (u_tz := obj.timezone) is not None else '' + return timezone_identifiers.get(timezone, timezone) @Account.views() diff --git a/funnel/views/api/account.py b/funnel/views/api/account.py index e09a83593..0aebafe44 100644 --- a/funnel/views/api/account.py +++ b/funnel/views/api/account.py @@ -7,9 +7,9 @@ from flask import request from baseframe import _ -from coaster.auth import current_auth from ... import app +from ...auth import current_auth from ...forms import PasswordPolicyForm, UsernameAvailableForm from ...typing import ReturnView from ..helpers import progressive_rate_limit_validator, validate_rate_limit @@ -78,7 +78,9 @@ def account_username_availability() -> ReturnView: # Allow user or source IP to check for up to 20 usernames every 10 minutes (600s) validate_rate_limit( 'account_username-available', - current_auth.actor.uuid_b58 if current_auth.actor else request.remote_addr, + current_auth.actor.uuid_b58 + if current_auth.actor + else (request.remote_addr or 'unknown-ipaddr'), # 20 username candidates 20, # per every 10 minutes (600s) diff --git a/funnel/views/api/geoname.py b/funnel/views/api/geoname.py index dc993a497..9d2db4715 100644 --- a/funnel/views/api/geoname.py +++ b/funnel/views/api/geoname.py @@ -8,7 +8,7 @@ from coaster.views import requestargs from ... import app -from ...models import GeoName +from ...models import GeoName, db from ...typing import ReturnView @@ -19,7 +19,7 @@ def geo_get_by_name( ) -> ReturnView: """Get a geoname record given a single URL stub name or geoname id.""" if name.isdigit(): - geoname = GeoName.query.get(int(name)) + geoname = db.session.get(GeoName, int(name)) else: geoname = GeoName.get(name) return ( @@ -43,7 +43,7 @@ def geo_get_by_names( geonames = [] for n in name: if n.isdigit(): - geoname = GeoName.query.get(int(n)) + geoname = db.session.get(GeoName, int(n)) else: geoname = GeoName.get(n) if geoname: diff --git a/funnel/views/api/oauth.py b/funnel/views/api/oauth.py index 945ae619d..3bb427d18 100644 --- a/funnel/views/api/oauth.py +++ b/funnel/views/api/oauth.py @@ -2,17 +2,24 @@ from __future__ import annotations -from collections.abc import Iterable -from typing import Optional, cast - -from flask import get_flashed_messages, jsonify, redirect, render_template, request +from collections.abc import Collection, Iterable +from typing import cast + +from flask import ( + abort, + get_flashed_messages, + jsonify, + redirect, + render_template, + request, +) from baseframe import _, forms -from coaster.auth import current_auth from coaster.sqlalchemy import failsafe_add from coaster.utils import newsecret from ... import app +from ...auth import current_auth from ...models import ( Account, AuthClient, @@ -178,8 +185,8 @@ def oauth_authorize() -> ReturnView: form = forms.Form() response_type = request.args.get('response_type') - client_id = cast(Optional[str], request.args.get('client_id')) - redirect_uri = cast(Optional[str], request.args.get('redirect_uri')) + client_id = request.args.get('client_id') + redirect_uri = request.args.get('redirect_uri') scope = cast(str, request.args.get('scope', '')).split(' ') state = cast(str, request.args.get('state', '')) @@ -196,7 +203,11 @@ def oauth_authorize() -> ReturnView: # Validation 1.2.1: Is the client active? if not auth_client.active: - return oauth_auth_error(auth_client.redirect_uri, state, 'unauthorized_client') + if auth_client.redirect_uri: + return oauth_auth_error( + auth_client.redirect_uri, state, 'unauthorized_client' + ) + abort(422) # Validation 1.3: Cross-check redirect_uri if not redirect_uri: @@ -207,7 +218,7 @@ def oauth_authorize() -> ReturnView: redirect_uri ): return oauth_auth_error( - auth_client.redirect_uri, + redirect_uri, state, 'invalid_request', _("Redirect URI hostname doesn't match"), @@ -216,7 +227,7 @@ def oauth_authorize() -> ReturnView: # Validation 1.4: AuthClient allows access for this user if not auth_client.allow_access_for(current_auth.user): return oauth_auth_error( - auth_client.redirect_uri, + redirect_uri, state, 'invalid_scope', _("You do not have access to this application"), @@ -349,7 +360,7 @@ def oauth_token_error( def oauth_make_token( user: Account | None, auth_client: AuthClient, - scope: Iterable, + scope: Collection[str], login_session: LoginSession | None = None, ) -> AuthToken: """Make an OAuth2 token for the given user, client, scope and optional session.""" @@ -364,28 +375,25 @@ def oauth_make_token( if auth_client.confidential: if user is None: raise ValueError("User not provided") - token = AuthToken( # nosec + token = AuthToken( # nosec B106 account=user, auth_client=auth_client, scope=scope, token_type='bearer' ) - token = cast( - AuthToken, - failsafe_add(db.session, token, account=user, auth_client=auth_client), + token = failsafe_add( + db.session, token, account=user, auth_client=auth_client ) + elif login_session is not None: - token = AuthToken( # nosec + token = AuthToken( # nosec B106 login_session=login_session, auth_client=auth_client, scope=scope, token_type='bearer', ) - token = cast( - AuthToken, - failsafe_add( - db.session, - token, - login_session=login_session, - auth_client=auth_client, - ), + token = failsafe_add( + db.session, + token, + login_session=login_session, + auth_client=auth_client, ) else: raise ValueError("login_session not provided") @@ -415,21 +423,18 @@ def oauth_token_success(token: AuthToken, **params) -> ReturnView: def oauth_token() -> ReturnView: """Provide token endpoint for OAuth2 server.""" # Always required parameters - grant_type = cast(Optional[str], request.form.get('grant_type')) + grant_type = request.form.get('grant_type') auth_client = current_auth.auth_client # Provided by @requires_client_login scope = request.form.get('scope', '').split(' ') # if grant_type == 'authorization_code' (POST) - code = cast(Optional[str], request.form.get('code')) - redirect_uri = cast(Optional[str], request.form.get('redirect_uri')) + code = request.form.get('code') + redirect_uri = request.form.get('redirect_uri') # if grant_type == 'password' (POST) - username = cast(Optional[str], request.form.get('username')) - password = cast(Optional[str], request.form.get('password')) + username = request.form.get('username') + password = request.form.get('password') # if grant_type == 'client_credentials' - buid = cast( - Optional[str], - # XXX: Deprecated userid parameter - request.form.get('buid') or request.form.get('userid'), - ) + # XXX: Deprecated userid parameter + buid = request.form.get('buid') or request.form.get('userid') # Validations 1: Required parameters if not grant_type: @@ -483,12 +488,12 @@ def oauth_token() -> ReturnView: db.session.delete(authcode) db.session.commit() return oauth_token_error('invalid_grant', _("Expired auth code")) - # Validations 3.1: scope in authcode + # Validations 3.1: scope in auth code if not scope or scope[0] == '': return oauth_token_error('invalid_scope', _("Scope is blank")) if not set(scope).issubset(set(authcode.scope)): return oauth_token_error('invalid_scope', _("Scope expanded")) - # Scope not exceeded. Use whatever the authcode allows + # Scope not exceeded. Use whatever the auth code allows scope = list(authcode.scope) if redirect_uri != authcode.redirect_uri: return oauth_token_error('invalid_client', _("redirect_uri does not match")) diff --git a/funnel/views/api/resource.py b/funnel/views/api/resource.py index 22226b961..3640c27ad 100644 --- a/funnel/views/api/resource.py +++ b/funnel/views/api/resource.py @@ -3,17 +3,17 @@ from __future__ import annotations from collections.abc import Container -from typing import Any, Literal, cast +from typing import Any, Literal from flask import abort, jsonify, render_template, request from werkzeug.datastructures import MultiDict from baseframe import __ -from coaster.auth import current_auth from coaster.utils import getbool from coaster.views import jsonp, requestargs from ... import app +from ...auth import current_auth from ...models import ( Account, AuthClient, @@ -547,7 +547,7 @@ def resource_login_providers( response = {} for extid in authtoken.effective_user.externalids: if service is None or extid.service == service: - response[cast(str, extid.service)] = { + response[extid.service] = { 'userid': str(extid.userid), 'username': str(extid.username), 'oauth_token': str(extid.oauth_token), diff --git a/funnel/views/api/shortlink.py b/funnel/views/api/shortlink.py index 68c657d41..4a020a4f2 100644 --- a/funnel/views/api/shortlink.py +++ b/funnel/views/api/shortlink.py @@ -4,11 +4,11 @@ from furl import furl from baseframe import _ -from coaster.auth import current_auth from coaster.utils import getbool from coaster.views import requestform from ... import app, shortlinkapp +from ...auth import current_auth from ...models import Shortlink, db from ..helpers import app_url_for, validate_is_app_url diff --git a/funnel/views/api/sms_events.py b/funnel/views/api/sms_events.py index 22053843d..932db32aa 100644 --- a/funnel/views/api/sms_events.py +++ b/funnel/views/api/sms_events.py @@ -9,10 +9,10 @@ from ... import app from ...models import ( - SMS_STATUS, PhoneNumber, PhoneNumberError, SmsMessage, + SmsStatusEnum, canonical_phone_number, db, sa, @@ -69,26 +69,26 @@ def process_twilio_event() -> ReturnView: if request.form['MessageStatus'] == 'queued': if sms_message: - sms_message.status = SMS_STATUS.QUEUED + sms_message.status = SmsStatusEnum.QUEUED elif request.form['MessageStatus'] == 'sent': if phone_number: phone_number.msg_sms_sent_at = sa.func.utcnow() if sms_message: - sms_message.status = SMS_STATUS.PENDING + sms_message.status = SmsStatusEnum.PENDING elif request.form['MessageStatus'] == 'failed': if phone_number: phone_number.msg_sms_failed_at = sa.func.utcnow() if sms_message: - sms_message.status = SMS_STATUS.FAILED + sms_message.status = SmsStatusEnum.FAILED elif request.form['MessageStatus'] == 'delivered': if phone_number: phone_number.msg_sms_delivered_at = sa.func.utcnow() phone_number.mark_has_sms(True) if sms_message: - sms_message.status = SMS_STATUS.DELIVERED + sms_message.status = SmsStatusEnum.DELIVERED else: if sms_message: - sms_message.status = SMS_STATUS.UNKNOWN + sms_message.status = SmsStatusEnum.UNKNOWN db.session.commit() current_app.logger.info( @@ -158,26 +158,26 @@ def process_exotel_event(secret_token: str) -> ReturnView: if request.form['Status'] == 'queued': if sms_message: - sms_message.status = SMS_STATUS.QUEUED + sms_message.status = SmsStatusEnum.QUEUED elif request.form['Status'] in ('sending', 'submitted'): if phone_number: phone_number.msg_sms_sent_at = sa.func.utcnow() if sms_message: - sms_message.status = SMS_STATUS.PENDING + sms_message.status = SmsStatusEnum.PENDING elif request.form['Status'] in ('failed', 'failed_dnd'): if phone_number: phone_number.msg_sms_failed_at = sa.func.utcnow() if sms_message: - sms_message.status = SMS_STATUS.FAILED + sms_message.status = SmsStatusEnum.FAILED elif request.form['Status'] == 'sent': if phone_number: phone_number.msg_sms_delivered_at = sa.func.utcnow() phone_number.mark_has_sms(True) if sms_message: - sms_message.status = SMS_STATUS.DELIVERED + sms_message.status = SmsStatusEnum.DELIVERED else: if sms_message: - sms_message.status = SMS_STATUS.UNKNOWN + sms_message.status = SmsStatusEnum.UNKNOWN db.session.commit() current_app.logger.info( diff --git a/funnel/views/api/support.py b/funnel/views/api/support.py index d495ae2af..6a314cf39 100644 --- a/funnel/views/api/support.py +++ b/funnel/views/api/support.py @@ -52,7 +52,7 @@ def support_callerid(number: str) -> tuple[dict[str, Any], int]: 'error_description': _("Unknown phone number"), }, 422 - info = { + info: dict[str, Any] = { 'number': phone_number.number, 'created_at': phone_number.created_at, 'active_at': phone_number.active_at, diff --git a/funnel/views/auth_client.py b/funnel/views/auth_client.py index 6eb7e2e36..364ff9c9b 100644 --- a/funnel/views/auth_client.py +++ b/funnel/views/auth_client.py @@ -6,7 +6,6 @@ from baseframe import _ from baseframe.forms import render_delete_sqla, render_form -from coaster.auth import current_auth from coaster.views import ( ClassView, ModelView, @@ -17,6 +16,7 @@ ) from .. import app +from ..auth import current_auth from ..forms import ( AuthClientCredentialForm, AuthClientForm, diff --git a/funnel/views/auth_notify.py b/funnel/views/auth_notify.py index 2162ee0e5..ed536848b 100644 --- a/funnel/views/auth_notify.py +++ b/funnel/views/auth_notify.py @@ -2,7 +2,7 @@ from __future__ import annotations -from ..models import Account, AuthToken, LoginSession, Organization, Team +from ..models import Account, AuthClient, AuthToken, LoginSession, Team from ..signals import ( org_data_changed, session_revoked, @@ -93,7 +93,7 @@ def notify_user_data_changed(user: Account, changes) -> None: @org_data_changed.connect def notify_org_data_changed( - org: Organization, user: Account, changes, team: Team | None = None + org: Account, user: Account, changes, team: Team | None = None ) -> None: """ Send notifications to trusted auth clients about org data changes. @@ -101,7 +101,7 @@ def notify_org_data_changed( Like :func:`notify_user_data_changed`, except also looks at all other owners of this org to find apps that need to be notified. """ - client_users = {} + client_users: dict[AuthClient, list[Account]] = {} for token in AuthToken.all(accounts=org.admin_users): if ( token.auth_client.trusted diff --git a/funnel/views/comment.py b/funnel/views/comment.py index c693a52ea..0c51e73f3 100644 --- a/funnel/views/comment.py +++ b/funnel/views/comment.py @@ -6,7 +6,6 @@ from baseframe import _, forms from baseframe.forms import Form, render_form -from coaster.auth import current_auth from coaster.views import ( ClassView, ModelView, @@ -18,6 +17,7 @@ ) from .. import app +from ..auth import current_auth from ..forms import CommentForm, CommentsetSubscribeForm from ..models import ( Account, diff --git a/funnel/views/contact.py b/funnel/views/contact.py index 062fec84a..2729bce6b 100644 --- a/funnel/views/contact.py +++ b/funnel/views/contact.py @@ -10,11 +10,11 @@ from sqlalchemy.exc import IntegrityError from baseframe import _ -from coaster.auth import current_auth from coaster.utils import getbool, make_name, midnight_to_utc, utcnow from coaster.views import ClassView, render_with, requestargs, route from .. import app +from ..auth import current_auth from ..models import ContactExchange, Project, TicketParticipant, db, sa_orm from ..typing import ReturnRenderWith, ReturnView from ..utils import format_twitter_handle @@ -47,7 +47,7 @@ def get_project(self, uuid_b58): @render_with('contacts.html.jinja2') def contacts(self) -> ReturnRenderWith: """Return contacts grouped by project and date.""" - archived = getbool(request.args.get('archived')) + archived = getbool(request.args.get('archived')) or False return { 'contacts': ContactExchange.grouped_counts_for( current_auth.user, archived=archived @@ -104,7 +104,7 @@ def contacts_to_csv(self, contacts, timezone, filename): @requires_login def project_date_csv(self, uuid_b58: str, datestr: str) -> ReturnView: """Return contacts for a given project and date in CSV format.""" - archived = getbool(request.args.get('archived')) + archived = getbool(request.args.get('archived')) or False project = self.get_project(uuid_b58) date = datetime.strptime(datestr, '%Y-%m-%d').date() @@ -121,7 +121,7 @@ def project_date_csv(self, uuid_b58: str, datestr: str) -> ReturnView: @requires_login def project_csv(self, uuid_b58: str) -> ReturnView: """Return contacts for a given project in CSV format.""" - archived = getbool(request.args.get('archived')) + archived = getbool(request.args.get('archived')) or False project = self.get_project(uuid_b58) contacts = ContactExchange.contacts_for_project( diff --git a/funnel/views/decorators.py b/funnel/views/decorators.py index aa2a5afee..b001c4adc 100644 --- a/funnel/views/decorators.py +++ b/funnel/views/decorators.py @@ -6,13 +6,12 @@ from datetime import datetime, timedelta from functools import wraps from hashlib import blake2b -from typing import cast from flask import Response, make_response, request, url_for from baseframe import cache -from coaster.auth import current_auth +from ..auth import current_auth from ..proxies import request_wants from ..typing import P, ReturnResponse, ReturnView, T from .helpers import compress_response, render_redirect @@ -59,7 +58,7 @@ def etag_cache_for_user( timeout: int, max_age: int | None = None, query_params: set | None = None, -) -> Callable[[Callable[P, ReturnView]], Callable[P, Response]]: +) -> Callable[[Callable[P, ReturnView]], Callable[P, ReturnView]]: """ Cache and compress a response, and add an ETag header for browser cache. @@ -72,9 +71,9 @@ def etag_cache_for_user( if max_age is None: max_age = timeout - def decorator(f: Callable[P, ReturnView]) -> Callable[P, Response]: + def decorator(f: Callable[P, ReturnView]) -> Callable[P, ReturnView]: @wraps(f) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnResponse: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnView: # No ETag or cache storage if the request is not GET or HEAD if request.method not in ('GET', 'HEAD'): return f(*args, **kwargs) @@ -87,7 +86,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnResponse: # Hash of request args, query parameters and common headers that influence # output. May need to be expanded to also add headers from Vary (which must # be specified in the decorator) - rhash = blake2b( + request_hash = blake2b( '\n'.join( [ request.headers.get('Accept', ''), @@ -112,13 +111,12 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnResponse: ).hexdigest() # 2. Get existing data from cache. There may be multiple copies of data, - # for each distinct rhash. Look for the one matching our rhash + # for each distinct request_hash. Look for the one matching our request_hash - # Optional[str] cache_data: dict | None = cache.get(cache_key) response_data = None if cache_data: - rhash_data = cache_data.get(rhash, {}) + rhash_data = cache_data.get(request_hash, {}) try: response_data = rhash_data['response_data'] content_encoding = rhash_data['content_encoding'] @@ -155,10 +153,10 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnResponse: chash = blake2b(response.get_data()).hexdigest() etag = blake2b( f'{identifier}/{view_version}/{current_auth.user.uuid_b64}' - f'/{chash}/{rhash}'.encode() + f'/{chash}/{request_hash}'.encode() ).hexdigest() last_modified = datetime.utcnow() - cache_data[rhash] = { + cache_data[request_hash] = { 'response_data': response_data, 'content_encoding': content_encoding, 'cash': chash, @@ -177,7 +175,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnResponse: response.cache_control.max_age = max_age response.expires = ( response.last_modified or datetime.utcnow() - ) + timedelta(seconds=cast(int, max_age)) + ) + timedelta(seconds=max_age) return response.make_conditional(request) diff --git a/funnel/views/errors.py b/funnel/views/errors.py index 4d5eb550d..bdda81e7c 100644 --- a/funnel/views/errors.py +++ b/funnel/views/errors.py @@ -2,9 +2,12 @@ from __future__ import annotations +from typing import cast + from flask import current_app, json, redirect, render_template, request from werkzeug.exceptions import HTTPException, MethodNotAllowed, NotFound from werkzeug.routing import RequestRedirect +from werkzeug.wrappers import Response from .. import app from ..models import db @@ -27,7 +30,7 @@ def handle_error(exc: HTTPException) -> ReturnView: """Render all errors with a custom template.""" db.session.rollback() json_response = request_wants.json or request.path.startswith('/api/') - response = exc.get_response() + response = cast(Response, exc.get_response()) if json_response: response.data = json.dumps( { diff --git a/funnel/views/helpers.py b/funnel/views/helpers.py index 63c43ce40..e4185e9f9 100644 --- a/funnel/views/helpers.py +++ b/funnel/views/helpers.py @@ -6,13 +6,12 @@ import zlib import zoneinfo from base64 import urlsafe_b64encode -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import nullcontext from datetime import datetime, timedelta from hashlib import blake2b from importlib import resources from os import urandom -from typing import Any from urllib.parse import quote, unquote, urljoin, urlsplit import brotli @@ -30,16 +29,17 @@ url_for, ) from furl import furl -from pytz import timezone as pytz_timezone, utc +from pytz import BaseTzInfo, timezone as pytz_timezone, utc from werkzeug.exceptions import MethodNotAllowed, NotFound from werkzeug.routing import BuildError, RequestRedirect +from werkzeug.wrappers import Response as BaseResponse from baseframe import cache, statsd -from coaster.auth import current_auth from coaster.sqlalchemy import RoleMixin from coaster.utils import utcnow from .. import app, shortlinkapp +from ..auth import current_auth from ..forms import supported_locales from ..models import Account, Shortlink, db, profanity from ..proxies import request_wants @@ -53,9 +53,11 @@ # --- Timezone data -------------------------------------------------------------------- # Get all known timezones from zoneinfo and make a lowercased lookup table -valid_timezones = {tz.lower(): tz for tz in zoneinfo.available_timezones()} +valid_timezones = {_tz.lower(): _tz for _tz in zoneinfo.available_timezones()} # Get timezone aliases from tzinfo.zi and place them in the lookup table -with resources.open_text('tzdata.zoneinfo', 'tzdata.zi') as _tzdata: +with (resources.files('tzdata.zoneinfo') / 'tzdata.zi').open( + 'r', encoding='utf-8', errors='strict' +) as _tzdata: for _tzline in _tzdata.readlines(): if _tzline.startswith('L'): _tzlink, _tznew, _tzold = _tzline.strip().split() @@ -90,14 +92,36 @@ def __delitem__(self, key: str) -> None: self.keys_at.remove(f'{key}_at') super().__delitem__(key) - def has_intersection(self, other: Any) -> bool: + def has_overlap_with(self, other: Mapping) -> bool: """Check for intersection with other dictionary-like object.""" okeys = other.keys() return not (self.keys_at.isdisjoint(okeys) and self.keys().isdisjoint(okeys)) + def crosscheck_session(self, response: ResponseType) -> ResponseType: + """Add timestamps to timed values in session, and remove expired values.""" + # Process timestamps only if there is at least one match. Most requests will + # have no match. + if self.has_overlap_with(session): + now = utcnow() + for var, delta in self.items(): + var_at = f'{var}_at' + if var in session: + if var_at not in session: + # Session has var but not timestamp, so add a timestamp + session[var_at] = now + elif session[var_at] < now - delta: + # Session var has expired, so remove var and timestamp + session.pop(var) + session.pop(var_at) + elif var_at in session: + # Timestamp present without var, so remove it + session.pop(var_at) + return response + #: Temporary values that must be periodically expunged from the cookie session session_timeouts = SessionTimeouts() +app.after_request(session_timeouts.crosscheck_session) # --- Utilities ------------------------------------------------------------------------ @@ -140,7 +164,11 @@ def app_url_for( The provided app must have `SERVER_NAME` in its config for URL construction to work. """ # pylint: disable=protected-access - if current_app and current_app._get_current_object() is target_app: + if ( + current_app + and current_app._get_current_object() # type: ignore[attr-defined] + is target_app + ): return url_for( endpoint, _external=_external, @@ -171,14 +199,16 @@ def app_url_for( def validate_is_app_url(url: str | furl, method: str = 'GET') -> bool: """Confirm if an external URL is served by the current app (runtime-only).""" # Parse or copy URL and remove username and password before further analysis - parsed_url = furl(url).remove(username=True, password=True) + if isinstance(url, str): + url = furl(url) + parsed_url = url.remove(username=True, password=True) if not parsed_url.host or not parsed_url.scheme: return False # This validator requires a full URL if current_app.url_map.host_matching: # This URL adapter matches explicit hosts, so we just give it the URL as its # server_name - server_name = parsed_url.netloc + server_name = parsed_url.netloc or '' # Fallback blank str for type checking subdomain = None else: # Next, validate whether the URL's host/netloc is valid for this app's config @@ -192,6 +222,7 @@ def validate_is_app_url(url: str | furl, method: str = 'GET') -> bool: parsed_url.netloc == server_name or ( current_app.subdomain_matching + and parsed_url.netloc is not None and parsed_url.netloc.endswith(f'.{server_name}') ) ): @@ -209,13 +240,13 @@ def validate_is_app_url(url: str | furl, method: str = 'GET') -> bool: return False server_name = request.host - # Host is validated, now make an adapter to match the path - adapter = current_app.url_map.bind( - server_name, - subdomain=subdomain, - script_name=current_app.config['APPLICATION_ROOT'], - url_scheme=current_app.config['PREFERRED_URL_SCHEME'], - ) + # Host is validated, now make an adapter to match the path + adapter = current_app.url_map.bind( + server_name, + subdomain=subdomain, + script_name=current_app.config['APPLICATION_ROOT'], + url_scheme=current_app.config['PREFERRED_URL_SCHEME'], + ) while True: # Keep looping on redirects try: @@ -226,15 +257,21 @@ def validate_is_app_url(url: str | furl, method: str = 'GET') -> bool: return False -def localize_micro_timestamp(timestamp, from_tz=utc, to_tz=utc): +def localize_micro_timestamp( + timestamp: float, from_tz: BaseTzInfo | str = utc, to_tz: BaseTzInfo | str = utc +) -> datetime: return localize_timestamp(int(timestamp) / 1000, from_tz, to_tz) -def localize_timestamp(timestamp, from_tz=utc, to_tz=utc): +def localize_timestamp( + timestamp: float, from_tz: BaseTzInfo | str = utc, to_tz: BaseTzInfo | str = utc +) -> datetime: return localize_date(datetime.fromtimestamp(int(timestamp)), from_tz, to_tz) -def localize_date(date, from_tz=utc, to_tz=utc): +def localize_date( + date: datetime, from_tz: BaseTzInfo | str = utc, to_tz: BaseTzInfo | str = utc +) -> datetime: if from_tz and to_tz: if isinstance(from_tz, str): from_tz = pytz_timezone(from_tz) @@ -333,10 +370,11 @@ def validate_rate_limit( :func:`progressive_rate_limit_validator` and its users. """ # statsd.set requires an ASCII string. The identifier parameter is typically UGC, - # meaning it can contain just about any character and any length. The identifier - # is hashed using BLAKE2b here to bring it down to a meaningful length. It is not + # meaning it can contain just about any character and any length. The identifier is + # hashed using BLAKE2b here to bring it down to a meaningful length. It is not # reversible should that be needed for debugging, but the obvious alternative Base64 - # encoding (for convering to 7-bit ASCII) cannot be used as it does not limit length + # encoding (for converting to 7-bit ASCII) cannot be used as it does not limit + # length statsd.set( 'rate_limit', blake2b(identifier.encode(), digest_size=32).hexdigest(), @@ -470,7 +508,7 @@ def decompress(data: bytes, algorithm: str) -> bytes: raise ValueError("Unknown compression algorithm") -def compress_response(response: ResponseType) -> None: +def compress_response(response: BaseResponse) -> None: """ Conditionally compress a response based on request parameters. @@ -609,29 +647,6 @@ def commit_db_session(response: ResponseType) -> ResponseType: return response -@app.after_request -def track_temporary_session_vars(response: ResponseType) -> ResponseType: - """Add timestamps to timed values in session, and remove expired values.""" - # Process timestamps only if there is at least one match. Most requests will - # have no match. - if session_timeouts.has_intersection(session): - for var, delta in session_timeouts.items(): - var_at = f'{var}_at' - if var in session: - if var_at not in session: - # Session has var but not timestamp, so add a timestamp - session[var_at] = utcnow() - elif session[var_at] < utcnow() - delta: - # Session var has expired, so remove var and timestamp - session.pop(var) - session.pop(var_at) - elif var_at in session: - # Timestamp present without var, so remove it - session.pop(var_at) - - return response - - @app.after_request def cache_expiry_headers(response: ResponseType) -> ResponseType: if response.expires is None: diff --git a/funnel/views/jobs.py b/funnel/views/jobs.py index 4a6f67d0f..78bae910f 100644 --- a/funnel/views/jobs.py +++ b/funnel/views/jobs.py @@ -61,7 +61,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T_co: @rqjob() def import_tickets(ticket_client_id: int) -> None: """Import tickets from Boxoffice.""" - ticket_client = TicketClient.query.get(ticket_client_id) + ticket_client = db.session.get(TicketClient, ticket_client_id) if ticket_client is not None: if ticket_client.name.lower() == 'explara': ticket_list = ExplaraAPI( @@ -79,7 +79,7 @@ def import_tickets(ticket_client_id: int) -> None: @rqjob() def tag_locations(project_id: int) -> None: """Tag a project with geoname locations. This is legacy code pending a rewrite.""" - project = Project.query.get(project_id) + project = db.session.get(Project, project_id) if project is None: return if not project.location: @@ -116,7 +116,7 @@ def tag_locations(project_id: int) -> None: project.parsed_location = {'tokens': tokens} for locdata in geonames.values(): - loc = ProjectLocation.query.get((project_id, locdata['geonameid'])) + loc = db.session.get(ProjectLocation, (project_id, locdata['geonameid'])) if loc is None: loc = ProjectLocation(project=project, geonameid=locdata['geonameid']) db.session.add(loc) diff --git a/funnel/views/label.py b/funnel/views/label.py index 6aad55b28..71dc80157 100644 --- a/funnel/views/label.py +++ b/funnel/views/label.py @@ -151,7 +151,7 @@ def edit(self) -> ReturnRenderWith: # existing option subl = Label.query.filter_by( project=self.obj.project, name=name - ).first() + ).one() subl.title = title subl.icon_emoji = emoji subl.seq = counter + 1 # Counter is 0-indexed, seq is 1-indexed diff --git a/funnel/views/login.py b/funnel/views/login.py index 805b8f700..cdd41b59d 100644 --- a/funnel/views/login.py +++ b/funnel/views/login.py @@ -22,11 +22,11 @@ from baseframe import _, __, forms, statsd from baseframe.forms import render_message from baseframe.signals import exception_catchall -from coaster.auth import current_auth from coaster.utils import getbool, utcnow from coaster.views import get_next_url, requestargs from .. import app +from ..auth import current_auth from ..forms import ( LoginForm, LoginPasswordResetException, @@ -44,6 +44,7 @@ AccountExternalId, AuthClientCredential, LoginSession, + User, db, getextid, merge_accounts, @@ -174,7 +175,7 @@ def login() -> ReturnView: if success: user = loginform.user if TYPE_CHECKING: - assert isinstance(user, Account) # nosec + assert isinstance(user, User) # nosec B101 login_internal(user, login_service='password') db.session.commit() if loginform.weak_password: @@ -278,7 +279,7 @@ def login() -> ReturnView: # Register an account user = register_internal(None, otp_form.fullname.data, None) if TYPE_CHECKING: - assert isinstance(user, Account) # nosec + assert isinstance(user, User) # nosec B101 if otp_session.email: db.session.add(user.add_email(otp_session.email, primary=True)) if otp_session.phone: @@ -408,7 +409,8 @@ def account_logout() -> ReturnView: for field_errors in form.errors.values(): for error in field_errors: - flash(error, 'error') + if error is not None: + flash(error, 'error') return render_redirect(url_for('account')) @@ -456,7 +458,9 @@ def login_service_callback(service: str) -> ReturnView: return login_service_postcallback(service, userdata) -def get_user_extid(service, userdata): +def get_user_extid( + service: str, userdata: LoginProviderData +) -> tuple[Account | None, AccountExternalId | None, AccountEmail | None]: """Retrieve user, extid and email from the given service and userdata.""" provider = login_registry[service] extid = getextid(service=service, userid=userdata.userid) @@ -587,6 +591,8 @@ def login_service_postcallback(service: str, userdata: LoginProviderData) -> Ret user.fullname = userdata.fullname if not current_auth: # If a user isn't already logged in, login now. + if TYPE_CHECKING: + assert isinstance(user, User) # nosec B101 login_internal(user, login_service=service) flash( _("You have logged in via {service}").format( @@ -629,6 +635,8 @@ def account_merge() -> ReturnView: if 'merge' in request.form: new_user = merge_accounts(current_auth.user, other_user) if new_user is not None: + if TYPE_CHECKING: + assert isinstance(new_user, User) # nosec B101 login_internal( new_user, login_service=current_auth.session.login_service @@ -759,6 +767,8 @@ def hasjobapp_login_callback(token): login_session = LoginSession.get(request_token['sessionid']) if login_session is not None: user = login_session.account + if TYPE_CHECKING: + assert isinstance(user, User) # nosec B101 login_internal(user, login_session) db.session.commit() flash(_("You are now logged in"), category='success') diff --git a/funnel/views/login_session.py b/funnel/views/login_session.py index 294c1be59..d6c0dfc70 100644 --- a/funnel/views/login_session.py +++ b/funnel/views/login_session.py @@ -25,11 +25,11 @@ from baseframe import _, __, statsd from baseframe.forms import render_form -from coaster.auth import add_auth_attribute, current_auth, request_has_auth from coaster.utils import utcnow from coaster.views import get_current_url, get_next_url from .. import app +from ..auth import add_auth_attribute, current_auth, request_has_auth from ..forms import OtpForm, PasswordForm from ..geoip import GeoIP2Error, geoip from ..models import ( @@ -49,7 +49,7 @@ from ..proxies import request_wants from ..serializers import lastuser_serializer from ..signals import user_login, user_registered -from ..typing import P, ResponseType, ReturnResponse, T +from ..typing import P, ResponseType, ReturnResponse, ReturnView, T from ..utils import abort_null from .helpers import ( app_context, @@ -389,7 +389,7 @@ def save_session_next_url() -> bool: return False -def reload_for_cookies(f: Callable[P, T]) -> Callable[P, T | ReturnResponse]: +def reload_for_cookies(f: Callable[P, ReturnView]) -> Callable[P, ReturnView]: """ Decorate a view to reload to obtain SameSite=strict cookies. @@ -405,7 +405,7 @@ def view() -> ReturnView: """ @wraps(f) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | ReturnResponse: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnView: if 'lastuser' not in request.cookies: add_auth_attribute('suppress_empty_cookie', True) attempt = request.args.get('cookiereload') @@ -426,15 +426,15 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | ReturnResponse: def requires_user_not_spammy( - get_current: Callable[P, str] | None = None -) -> Callable[[Callable[P, T]], Callable[P, T | ReturnResponse]]: + get_current: Callable[..., str] | None = None +) -> Callable[[Callable[P, ReturnView]], Callable[P, ReturnView]]: """Decorate a view to require the user to prove they are not likely a spammer.""" - def decorator(f: Callable[P, T]) -> Callable[P, T | ReturnResponse]: + def decorator(f: Callable[P, ReturnView]) -> Callable[P, ReturnView]: """Apply decorator using the specified :attr:`get_current` function.""" @wraps(f) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | ReturnResponse: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnView: """Validate user rights in a view.""" if not current_auth.is_authenticated: flash(_("Confirm your phone number to continue"), 'info') @@ -543,7 +543,7 @@ def del_sudo_preference_context() -> None: session.pop('sudo_context', None) -def requires_sudo(f: Callable[P, T]) -> Callable[P, T | ReturnResponse]: +def requires_sudo(f: Callable[P, ReturnView]) -> Callable[P, ReturnView]: """ Decorate a view to require the current user to have re-authenticated recently. @@ -555,7 +555,7 @@ def requires_sudo(f: Callable[P, T]) -> Callable[P, T | ReturnResponse]: """ @wraps(f) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | ReturnResponse: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> ReturnView: """Prompt for re-authentication to proceed.""" add_auth_attribute('login_required', True) # If the user is not logged in, require login first @@ -661,7 +661,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | ReturnResponse: abort(422) # Allow 5 password or OTP guesses per 60 seconds - validate_rate_limit('account_sudo', current_auth.user.userid, 5, 60) + validate_rate_limit('account_sudo', current_auth.user.uuid_b64, 5, 60) if form.validate_on_submit(): # User has successfully authenticated. Update their sudo timestamp # and reload the page with a GET request, as the wrapped view may diff --git a/funnel/views/membership.py b/funnel/views/membership.py index 8da6f48cf..5a13a6c2e 100644 --- a/funnel/views/membership.py +++ b/funnel/views/membership.py @@ -6,7 +6,6 @@ from baseframe import _ from baseframe.forms import Form, render_form -from coaster.auth import current_auth from coaster.views import ( ModelView, UrlChangeCheck, @@ -17,6 +16,7 @@ ) from .. import app, signals +from ..auth import current_auth from ..forms import ( OrganizationMembershipForm, ProjectCrewMembershipForm, diff --git a/funnel/views/mixins.py b/funnel/views/mixins.py index b87524b15..1cbaadf8c 100644 --- a/funnel/views/mixins.py +++ b/funnel/views/mixins.py @@ -10,17 +10,17 @@ from werkzeug.datastructures import MultiDict from baseframe import _, forms -from coaster.auth import current_auth from coaster.views import ModelView, UrlChangeCheck, UrlForView, route +from ..auth import current_auth from ..forms import SavedProjectForm from ..models import ( Account, Draft, + ModelUuidProtocol, Project, ProjectRedirect, TicketEvent, - UuidModelUnion, db, ) from ..typing import ReturnView @@ -133,14 +133,14 @@ class DraftViewProtoMixin: model: Any obj: Any - def get_draft(self, obj: UuidModelUnion | None = None) -> Draft | None: + def get_draft(self, obj: ModelUuidProtocol | None = None) -> Draft | None: """ Return the draft object for `obj`. Defaults to `self.obj`. `obj` is needed in case of multi-model views. """ obj = obj if obj is not None else self.obj - return Draft.query.get((self.model.__tablename__, obj.uuid)) + return db.session.get(Draft, (self.model.__tablename__, obj.uuid)) def delete_draft(self, obj=None): """Delete draft for `obj`, or `self.obj` if `obj` is `None`.""" @@ -151,7 +151,7 @@ def delete_draft(self, obj=None): raise ValueError(_("There is no draft for the given object")) def get_draft_data( - self, obj: UuidModelUnion | None = None + self, obj: ModelUuidProtocol | None = None ) -> tuple[None, None] | tuple[UUID | None, dict]: """ Return a tuple of draft data. @@ -163,7 +163,7 @@ def get_draft_data( return draft.revision, draft.formdata return None, None - def autosave_post(self, obj: UuidModelUnion | None = None) -> ReturnView: + def autosave_post(self, obj: ModelUuidProtocol | None = None) -> ReturnView: """Handle autosave POST requests.""" obj = obj if obj is not None else self.obj if 'form.revision' not in request.form: diff --git a/funnel/views/notification.py b/funnel/views/notification.py index bcc02efc8..57e90fa29 100644 --- a/funnel/views/notification.py +++ b/funnel/views/notification.py @@ -9,7 +9,7 @@ from email.utils import formataddr from functools import wraps from itertools import islice -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, cast from uuid import UUID, uuid4 from flask import url_for @@ -17,19 +17,20 @@ from werkzeug.utils import cached_property from baseframe import __, statsd -from coaster.auth import current_auth from coaster.sqlalchemy import RoleAccessProxy from .. import app +from ..auth import current_auth from ..models import ( Account, AccountEmail, AccountPhone, + ModelUuidProtocol, Notification, NotificationFor, NotificationRecipient, - UuidModelUnion, db, + sa, ) from ..serializers import token_serializer from ..transports import TransportError, email, platform_transports, sms @@ -253,11 +254,15 @@ def unsubscribe_token(self, transport: str) -> str: # This payload is consumed by :meth:`AccountNotificationView.unsubscribe` # in `views/notification_preferences.py` return token_serializer().dumps( + # pylint: disable=used-before-assignment + # https://github.com/pylint-dev/pylint/issues/8486 { 'buid': self.notification_recipient.recipient.buid, 'notification_type': self.notification.type, 'transport': transport, - 'hash': self.transport_for(transport).transport_hash, + 'hash': anchor.transport_hash + if (anchor := self.transport_for(transport)) is not None + else '', } ) @@ -281,11 +286,15 @@ def unsubscribe_short_url(self, transport: str = 'sms') -> str: # use this, and can't add utm_* tags to the URL as it only examines the token # after cleaning up the URL, so there are no more redirects left. token = make_cached_token( + # pylint: disable=used-before-assignment + # https://github.com/pylint-dev/pylint/issues/8486 { 'buid': self.notification_recipient.recipient.buid, 'notification_type': self.notification.type, 'transport': transport, - 'hash': self.transport_for(transport).transport_hash, + 'hash': anchor.transport_hash + if (anchor := self.transport_for(transport)) is not None + else '', 'eventid_b58': self.notification.eventid_b58, 'timestamp': datetime.utcnow(), # Naive timestamp }, @@ -299,7 +308,7 @@ def unsubscribe_short_url(self, transport: str = 'sms') -> str: return url_for('notification_unsubscribe_short', token=token, _external=True) @cached_property - def fragments_order_by(self) -> list: # TODO: Full spec + def fragments_order_by(self) -> list[sa.UnaryExpression]: """Provide a list of order_by columns for loading fragments.""" if self.notification.fragment_model is None: return [] @@ -310,20 +319,21 @@ def fragments_order_by(self) -> list: # TODO: Full spec ] @property - def fragments_query_options(self) -> list: # TODO: full spec + def fragments_query_options(self) -> Sequence: """Provide a list of SQLAlchemy options for loading fragments.""" return [] @cached_property - def fragments(self) -> list[RoleAccessProxy[UuidModelUnion]]: - if not self.notification.fragment_model: + def fragments( + self, + ) -> list[RoleAccessProxy[ModelUuidProtocol]]: # type: ignore[type-var] # FIXME + query = self.notification_recipient.rolledup_fragments() + if query is None: return [] - query = self.notification_recipient.rolledup_fragments().order_by( - *self.fragments_order_by - ) - if self.fragments_query_options: - query = query.options(*self.fragments_query_options) + query = query.order_by(*self.fragments_order_by) + if query_options := self.fragments_query_options: + query = query.options(*query_options) return [ _f.access_for(actor=self.notification_recipient.recipient) @@ -358,7 +368,7 @@ def web(self) -> str: @property def email_base_url(self) -> str: """Base URL for relative links in email.""" - return self.notification.role_provider_obj.absolute_url + return cast(str, self.notification.role_provider_obj.absolute_url) def email_subject(self) -> str: """ @@ -388,6 +398,12 @@ def email_from(self) -> str: return f"{self.notification.preference_context.title} (via Hasgeek)" return "Hasgeek" + def sms_with_unsubscribe(self) -> sms.SmsTemplate: + """Add an unsubscribe link to the SMS message.""" + msg = self.sms() + msg.unsubscribe_url = self.unsubscribe_short_url('sms') + return msg + def sms(self) -> sms.SmsTemplate: """ Render a short text message. Templates must use a single line with a link. @@ -400,12 +416,6 @@ def text(self) -> str: """Render a short plain text notification using the SMS template.""" return self.sms().text - def sms_with_unsubscribe(self) -> sms.SmsTemplate: - """Add an unsubscribe link to the SMS message.""" - msg = self.sms() - msg.unsubscribe_url = self.unsubscribe_short_url('sms') - return msg - def webpush(self) -> str: """ Render a web push notification. @@ -499,7 +509,7 @@ def transport_worker_wrapper( def inner(notification_recipient_ids: Sequence[tuple[int, UUID]]) -> None: """Convert a notification id into an object for worker to process.""" queue = [ - NotificationRecipient.query.get(identity) + db.session.get(NotificationRecipient, identity) for identity in notification_recipient_ids ] for notification_recipient in queue: @@ -617,7 +627,9 @@ def dispatch_transport_sms( @rqjob() def dispatch_notification_job(eventid: UUID, notification_ids: Sequence[UUID]) -> None: """Process :class:`Notification` into batches of :class:`UserNotification`.""" - notifications = [Notification.query.get((eventid, nid)) for nid in notification_ids] + notifications = [ + db.session.get(Notification, (eventid, nid)) for nid in notification_ids + ] # Dispatch, creating batches of DISPATCH_BATCH_SIZE each for notification in notifications: @@ -645,7 +657,7 @@ def dispatch_notification_recipients_job( """Process notifications for users and enqueue transport delivery.""" # TODO: Can this be a single query instead of a loop of queries? queue = [ - NotificationRecipient.query.get(identity) + db.session.get(NotificationRecipient, identity) for identity in notification_recipient_ids ] transport_batch: dict[str, list[tuple[int, UUID]]] = defaultdict(list) diff --git a/funnel/views/notification_feed.py b/funnel/views/notification_feed.py index df3faeb9d..452dd996d 100644 --- a/funnel/views/notification_feed.py +++ b/funnel/views/notification_feed.py @@ -5,10 +5,10 @@ from flask import abort from baseframe import forms -from coaster.auth import current_auth from coaster.views import ClassView, render_with, requestargs, route from .. import app +from ..auth import current_auth from ..models import NotificationRecipient, db from ..typing import ReturnRenderWith from .login_session import requires_login diff --git a/funnel/views/notification_preferences.py b/funnel/views/notification_preferences.py index 52c6a54fa..99f4fd4f3 100644 --- a/funnel/views/notification_preferences.py +++ b/funnel/views/notification_preferences.py @@ -9,11 +9,11 @@ from baseframe import _, __ from baseframe.forms import render_form, render_message -from coaster.auth import current_auth from coaster.utils import getbool from coaster.views import ClassView, render_with, requestargs, route from .. import app +from ..auth import current_auth from ..forms import SetNotificationPreferenceForm, UnsubscribeForm, transport_labels from ..models import ( Account, diff --git a/funnel/views/notifications/comment_notification.py b/funnel/views/notifications/comment_notification.py index 574066536..75d007cbd 100644 --- a/funnel/views/notifications/comment_notification.py +++ b/funnel/views/notifications/comment_notification.py @@ -1,7 +1,9 @@ -"""̌Comment noficiations.""" +"""Comment notifications.""" from __future__ import annotations +from typing import cast + from flask import render_template, url_for from markupsafe import Markup, escape from werkzeug.utils import cached_property @@ -15,7 +17,6 @@ CommentReplyNotification, CommentReportReceivedNotification, Commentset, - DuckTypeAccount, NewCommentNotification, Project, Proposal, @@ -129,10 +130,16 @@ class CommentNotification(RenderNotification): hero_image = 'img/email/chars-v1/comment.png' email_heading = __("New comment!") + @cached_property + def commentset(self) -> Commentset: + if isinstance(self.document, Commentset): + return self.document + return self.document.commentset + @property - def actor(self) -> Account | DuckTypeAccount: + def actor(self) -> Account: """Actor who commented.""" - return self.comment.posted_by + return cast(Account, self.comment.posted_by) @cached_property def commenters(self) -> list[Account]: @@ -150,16 +157,16 @@ def commenters(self) -> list[Account]: @property def project(self) -> Project | None: - if self.document_type == 'project': - return self.document.project - if self.document_type == 'proposal': - return self.document.proposal.project + if (document_type := self.document_type) == 'project': + return self.commentset.project + if document_type == 'proposal': + return self.commentset.proposal.project # type: ignore[union-attr] return None @property def proposal(self) -> Proposal | None: if self.document_type == 'proposal': - return self.document.proposal + return self.commentset.proposal return None @property @@ -167,25 +174,25 @@ def document_type(self) -> str: """Return type of document this comment is on ('comment' for replies).""" if self.notification.document_type == 'comment': return 'comment' - return self.document.parent_type + return self.commentset.parent_type def document_comments_url(self, **kwargs) -> str: """URL to comments view on the document.""" - if self.document_type == 'project': - return self.document.parent.url_for('comments', **kwargs) - if self.document_type == 'proposal': - return self.document.parent.url_for('view', **kwargs) + '#comments' + if (document_type := self.document_type) == 'project': + return self.commentset.parent.url_for('comments', **kwargs) + if document_type == 'proposal': + return self.commentset.parent.url_for('view', **kwargs) + '#comments' return self.document.url_for('view', **kwargs) def activity_template_standalone(self, comment: Comment | None = None) -> str: """Activity template for standalone use, such as email subject.""" if comment is None: comment = self.comment - if self.document_type == 'comment': + if (document_type := self.document_type) == 'comment': return _("{actor} replied to your comment in {project}") - if self.document_type == 'project': + if document_type == 'project': return _("{actor} commented in {project}") - if self.document_type == 'proposal': + if document_type == 'proposal': return _("{actor} commented on {proposal}") # Unknown document type return _("{actor} replied to you") @@ -194,11 +201,11 @@ def activity_template_inline(self, comment: Comment | None = None) -> str: """Activity template for inline use with other content, like SMS with URL.""" if comment is None: comment = self.comment - if self.document_type == 'comment': + if (document_type := self.document_type) == 'comment': return _("{actor} replied to your comment in {project}:") - if self.document_type == 'project': + if document_type == 'project': return _("{actor} commented in {project}:") - if self.document_type == 'proposal': + if document_type == 'proposal': return _("{actor} commented on {proposal}:") # Unknown document type return _("{actor} replied to you:") diff --git a/funnel/views/notifications/organization_membership_notification.py b/funnel/views/notifications/organization_membership_notification.py index 97faf526e..0c7737e42 100644 --- a/funnel/views/notifications/organization_membership_notification.py +++ b/funnel/views/notifications/organization_membership_notification.py @@ -8,16 +8,19 @@ from flask import render_template from markupsafe import Markup, escape +from werkzeug.utils import cached_property from baseframe import _, __ from ...models import ( Account, AccountMembership, + MembershipRecordTypeEnum, + Notification, NotificationRecipient, - NotificationType, OrganizationAdminMembershipNotification, OrganizationAdminMembershipRevokedNotification, + sa, ) from ...transports.sms import MessageTemplate from ..notification import DecisionBranchBase, DecisionFactorBase, RenderNotification @@ -29,7 +32,7 @@ class DecisionFactorFields: is_member: bool | None = None for_actor: bool | None = None - rtypes: Collection[str] = () + rtypes: Collection[MembershipRecordTypeEnum] = () is_owner: bool | None = None is_actor: bool | None = None @@ -43,7 +46,7 @@ def is_match( return ( (self.is_member is None or self.is_member is is_member) and (self.for_actor is None or self.for_actor is for_actor) - and (not self.rtypes or membership.record_type_label.name in self.rtypes) + and (not self.rtypes or membership.record_type_enum in self.rtypes) and (self.is_owner is None or self.is_owner is membership.is_owner) and (self.is_actor is None or (self.is_actor is membership.is_self_granted)) ) @@ -63,7 +66,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): grant_amend_templates = DecisionBranch( factors=[ DecisionBranch( - rtypes=['invite'], + rtypes=[MembershipRecordTypeEnum.INVITE], factors=[ DecisionBranch( for_actor=False, @@ -121,7 +124,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): ], ), DecisionBranch( - rtypes=['direct_add'], + rtypes=[MembershipRecordTypeEnum.DIRECT_ADD], factors=[ DecisionBranch( for_actor=False, @@ -169,7 +172,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): ], ), DecisionBranch( - rtypes=['accept'], + rtypes=[MembershipRecordTypeEnum.ACCEPT], factors=[ DecisionBranch( for_actor=False, @@ -229,7 +232,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): ], ), DecisionBranch( - rtypes=['amend'], + rtypes=[MembershipRecordTypeEnum.AMEND], factors=[ DecisionBranch( for_actor=False, @@ -345,7 +348,7 @@ class RenderShared: organization: Account membership: AccountMembership - notification: NotificationType + notification: Notification notification_recipient: NotificationRecipient template_picker: DecisionBranch @@ -439,7 +442,9 @@ class RenderOrganizationAdminMembershipNotification(RenderShared, RenderNotifica email_heading = __("Membership granted!") template_picker = grant_amend_templates - fragments_order_by = [AccountMembership.granted_at.desc()] + @cached_property + def fragments_order_by(self) -> list[sa.UnaryExpression]: + return [AccountMembership.granted_at.desc()] def membership_actor( self, membership: AccountMembership | None = None @@ -472,7 +477,9 @@ class RenderOrganizationAdminMembershipRevokedNotification( email_heading = __("Membership revoked") template_picker = revoke_templates - fragments_order_by = [AccountMembership.revoked_at.desc()] + @cached_property + def fragments_order_by(self) -> list[sa.UnaryExpression]: + return [AccountMembership.revoked_at.desc()] def membership_actor( self, membership: AccountMembership | None = None diff --git a/funnel/views/notifications/project_crew_notification.py b/funnel/views/notifications/project_crew_notification.py index e6e3d1227..3c7c08109 100644 --- a/funnel/views/notifications/project_crew_notification.py +++ b/funnel/views/notifications/project_crew_notification.py @@ -4,21 +4,24 @@ from collections.abc import Callable, Collection from dataclasses import dataclass -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from flask import render_template from markupsafe import Markup, escape +from werkzeug.utils import cached_property from baseframe import _, __ from ...models import ( Account, + MembershipRecordTypeEnum, + Notification, NotificationRecipient, - NotificationType, Project, ProjectCrewMembershipNotification, ProjectCrewMembershipRevokedNotification, ProjectMembership, + sa, ) from ...transports.sms import OneLineTemplate from ..helpers import shortlink @@ -31,7 +34,7 @@ class DecisionFactorFields: is_member: bool | None = None for_actor: bool | None = None - rtypes: Collection[str] = () + rtypes: Collection[MembershipRecordTypeEnum] = () is_editor: bool | None = None is_promoter: bool | None = None is_usher: bool | None = None @@ -46,7 +49,7 @@ def is_match( return ( (self.is_member is None or self.is_member is is_member) and (self.for_actor is None or self.for_actor is for_actor) - and (not self.rtypes or membership.record_type_label.name in self.rtypes) + and (not self.rtypes or membership.record_type_enum in self.rtypes) and (self.is_editor is None or self.is_editor is membership.is_editor) and (self.is_promoter is None or self.is_promoter is membership.is_promoter) and (self.is_usher is None or self.is_usher is membership.is_usher) @@ -77,7 +80,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): grant_amend_templates = DecisionBranch( factors=[ DecisionBranch( - rtypes=['invite'], + rtypes=[MembershipRecordTypeEnum.INVITE], factors=[ DecisionBranch( for_actor=False, @@ -142,7 +145,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): "{actor} invited you to join the crew of {project}" ), is_member=True, - rtypes=['invite'], + rtypes=[MembershipRecordTypeEnum.INVITE], ), ], ), @@ -172,7 +175,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): template=__( "You invited {user} to join the crew of {project}" ), - rtypes=['invite'], + rtypes=[MembershipRecordTypeEnum.INVITE], for_actor=True, ), ], @@ -180,7 +183,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): ], ), DecisionBranch( - rtypes=['accept'], + rtypes=[MembershipRecordTypeEnum.ACCEPT], factors=[ DecisionBranch( for_actor=False, @@ -248,7 +251,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): ], ), DecisionBranch( - rtypes=['direct_add'], + rtypes=[MembershipRecordTypeEnum.DIRECT_ADD], factors=[ DecisionBranch( for_actor=False, @@ -344,7 +347,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): ), DecisionFactor( template=__("You made {user} promoter of {project}"), - rtypes=['direct_add'], + rtypes=[MembershipRecordTypeEnum.DIRECT_ADD], is_promoter=True, ), DecisionFactor( @@ -377,7 +380,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): ], ), DecisionBranch( - rtypes=['amend'], + rtypes=[MembershipRecordTypeEnum.AMEND], factors=[ DecisionBranch( for_actor=False, @@ -530,7 +533,7 @@ class DecisionBranch(DecisionFactorFields, DecisionBranchBase): template=__( "You changed your role to crew member of {project}" ), - rtypes=['amend'], + rtypes=[MembershipRecordTypeEnum.AMEND], is_self_granted=True, ), ], @@ -667,7 +670,7 @@ class RenderShared: project: Project membership: ProjectMembership - notification: NotificationType + notification: Notification notification_recipient: NotificationRecipient #: Subclasses must specify a base template picker template_picker: DecisionBranch @@ -771,12 +774,18 @@ class RenderProjectCrewMembershipNotification(RenderShared, RenderNotification): aliases = {'document': 'project', 'fragment': 'membership'} hero_image = 'img/email/chars-v1/access-granted.png' email_heading = __("Crew membership granted!") - fragments_order_by = [ProjectMembership.granted_at.desc()] template_picker = grant_amend_templates + @cached_property + def fragments_order_by(self) -> list[sa.UnaryExpression]: + return [ProjectMembership.granted_at.desc()] + def membership_actor(self, membership: ProjectMembership | None = None) -> Account: """Actual actor who granted (or edited) the membership, for the template.""" - return (membership or self.membership).granted_by + actor = (membership or self.membership).granted_by + if TYPE_CHECKING: + assert actor is not None # nosec B101 + return actor def web(self): return render_template( diff --git a/funnel/views/notifications/proposal_notification.py b/funnel/views/notifications/proposal_notification.py index f80052951..9a13ec1c9 100644 --- a/funnel/views/notifications/proposal_notification.py +++ b/funnel/views/notifications/proposal_notification.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Sequence + from flask import render_template from werkzeug.utils import cached_property @@ -12,6 +14,7 @@ Proposal, ProposalReceivedNotification, ProposalSubmittedNotification, + sa, sa_orm, ) from ...transports.sms import SmsPriority, SmsTemplate @@ -71,11 +74,11 @@ class RenderProposalReceivedNotification(RenderNotification): email_heading = __("New submission!") @cached_property - def fragments_order_by(self): + def fragments_order_by(self) -> list[sa.UnaryExpression]: return [Proposal.datetime.desc()] @property - def fragments_query_options(self): + def fragments_query_options(self) -> Sequence: return [ sa_orm.load_only( Proposal.name, Proposal.title, Proposal.project_id, Proposal.uuid @@ -123,7 +126,7 @@ class RenderProposalSubmittedNotification(RenderNotification): emoji_prefix = "📤 " reason = __("You are receiving this because you made this submission") hero_image = 'img/email/chars-v1/sent-submission.png' - email_heading = __("Proposal sumbitted!") + email_heading = __("Proposal submitted!") def web(self) -> str: return render_template( diff --git a/funnel/views/organization.py b/funnel/views/organization.py index bf62a912f..08b3b18a8 100644 --- a/funnel/views/organization.py +++ b/funnel/views/organization.py @@ -6,10 +6,10 @@ from baseframe import _ from baseframe.forms import render_delete_sqla, render_form, render_message -from coaster.auth import current_auth from coaster.views import ModelView, UrlChangeCheck, UrlForView, requires_roles, route from .. import app +from ..auth import current_auth from ..forms import OrganizationForm, TeamForm from ..models import Account, Organization, Team, db from ..signals import org_data_changed, team_data_changed diff --git a/funnel/views/otp.py b/funnel/views/otp.py index cc9c7b711..16746f68d 100644 --- a/funnel/views/otp.py +++ b/funnel/views/otp.py @@ -12,15 +12,16 @@ from werkzeug.utils import cached_property from baseframe import _ -from coaster.auth import current_auth from coaster.utils import newpin, require_one_of from .. import app +from ..auth import current_auth from ..models import ( Account, AccountEmail, AccountEmailClaim, AccountPhone, + Anchor, EmailAddress, EmailAddressBlockedError, PhoneNumber, @@ -134,7 +135,7 @@ def make( cls: type[OtpSessionType], reason: str, user: OptionalAccountType, - anchor: AccountEmail | AccountEmailClaim | AccountPhone | EmailAddress | None, + anchor: Anchor | None, phone: str | None = None, email: str | None = None, ) -> OtpSessionType: diff --git a/funnel/views/profile.py b/funnel/views/profile.py index 455dd3e2f..e253843d0 100644 --- a/funnel/views/profile.py +++ b/funnel/views/profile.py @@ -2,12 +2,13 @@ from __future__ import annotations +from typing import Any + from flask import abort, current_app, flash, render_template, request from baseframe import _ from baseframe.filters import date_filter from baseframe.forms import render_form -from coaster.auth import current_auth from coaster.views import ( UrlChangeCheck, get_next_url, @@ -18,6 +19,7 @@ ) from .. import app +from ..auth import current_auth from ..forms import ( ProfileBannerForm, ProfileForm, @@ -77,7 +79,7 @@ class ProfileView(UrlChangeCheck, AccountViewBase): @render_with({'text/html': template_switcher}, json=True) def view(self) -> ReturnRenderWith: template_name = None - ctx = {} + ctx: dict[str, Any] = {} if self.obj.is_user_profile: template_name = 'user_profile.html.jinja2' @@ -173,7 +175,7 @@ def view(self) -> ReturnRenderWith: # If the user is an admin of this account, show all draft projects. # Else, only show the drafts they have a crew role in if self.obj.current_roles.admin: - draft_projects = self.obj.draft_projects + draft_projects: list[Project] = self.obj.draft_projects.all() unscheduled_projects = self.obj.projects.filter( Project.state.PUBLISHED_WITHOUT_SESSIONS ).all() @@ -278,7 +280,7 @@ def user_proposals(self) -> ReturnRenderWith: @route('past.projects') @requestargs(('page', int), ('per_page', int)) @render_with('past_projects_section.html.jinja2') - def past_projects(self, page: int = 1, per_page: int = 10) -> ReturnView: + def past_projects(self, page: int = 1, per_page: int = 10) -> ReturnRenderWith: projects = self.obj.listed_projects.order_by(None) past_projects = projects.filter(Project.state.PAST).order_by( Project.start_at.desc() @@ -305,7 +307,7 @@ def past_projects(self, page: int = 1, per_page: int = 10) -> ReturnView: @route('past.sessions') @requestargs(('page', int), ('per_page', int)) @render_with('past_sessions_section.html.jinja2') - def past_sessions(self, page: int = 1, per_page: int = 10) -> ReturnView: + def past_sessions(self, page: int = 1, per_page: int = 10) -> ReturnRenderWith: featured_sessions = ( Session.query.join(Project, Session.project_id == Project.id) .filter( diff --git a/funnel/views/project.py b/funnel/views/project.py index 98d713f55..5a41ec5c2 100644 --- a/funnel/views/project.py +++ b/funnel/views/project.py @@ -12,11 +12,11 @@ from baseframe import _, __, forms from baseframe.forms import render_delete_sqla, render_form, render_message -from coaster.auth import current_auth from coaster.utils import getbool, make_name from coaster.views import get_next_url, render_with, requires_roles, route from .. import app +from ..auth import current_auth from ..forms import ( CfpForm, ProjectBannerForm, @@ -30,13 +30,13 @@ ProjectTransitionForm, ) from ..models import ( - PROJECT_RSVP_STATE, - RSVP_STATUS, Account, Project, + ProjectRsvpStateEnum, RegistrationCancellationNotification, RegistrationConfirmationNotification, Rsvp, + RsvpStateEnum, SavedProject, db, sa, @@ -164,9 +164,9 @@ def feature_project_rsvp(obj: Project) -> bool: obj.state.PUBLISHED and (obj.start_at is None or not obj.state.PAST) and ( - obj.rsvp_state == PROJECT_RSVP_STATE.ALL + obj.rsvp_state == ProjectRsvpStateEnum.ALL or ( - obj.rsvp_state == PROJECT_RSVP_STATE.MEMBERS + obj.rsvp_state == ProjectRsvpStateEnum.MEMBERS and obj.current_roles.account_member ) ) @@ -178,7 +178,7 @@ def feature_project_rsvp_for_members(obj: Project) -> bool: return bool( obj.state.PUBLISHED and (obj.start_at is None or not obj.state.PAST) - and obj.rsvp_state == PROJECT_RSVP_STATE.MEMBERS + and obj.rsvp_state == ProjectRsvpStateEnum.MEMBERS ) @@ -222,7 +222,7 @@ def feature_project_register(obj: Project) -> bool: @Project.features('rsvp_registered', cached_property=True) def feature_project_deregister(obj: Project) -> bool: rsvp = obj.rsvp_for(current_auth.user) - return rsvp is not None and rsvp.state.YES + return rsvp is not None and bool(rsvp.state.YES) @Project.features('schedule_no_sessions') @@ -744,7 +744,7 @@ def rsvp_list(self) -> ReturnRenderWith: 'project': self.obj.current_access(datasets=('primary', 'related')), 'going_rsvps': [ _r.current_access(datasets=('without_parent', 'related', 'related')) - for _r in self.obj.rsvps_with(RSVP_STATUS.YES) + for _r in self.obj.rsvps_with(RsvpStateEnum.YES) ], 'rsvp_form_fields': [ field.get('name', '') @@ -755,7 +755,7 @@ def rsvp_list(self) -> ReturnRenderWith: else None, } - def get_rsvp_state_csv(self, state): + def get_rsvp_state_csv(self, state: RsvpStateEnum) -> Response: """Export participant list as a CSV.""" outfile = io.StringIO(newline='') out = csv.writer(outfile) @@ -789,14 +789,14 @@ def get_rsvp_state_csv(self, state): @requires_roles({'promoter'}) def rsvp_list_yes_csv(self) -> ReturnView: """Return a CSV of RSVP participants who answered Yes.""" - return self.get_rsvp_state_csv(state=RSVP_STATUS.YES) + return self.get_rsvp_state_csv(RsvpStateEnum.YES) @route('rsvp_list/maybe.csv') @requires_login @requires_roles({'promoter'}) def rsvp_list_maybe_csv(self) -> ReturnView: """Return a CSV of RSVP participants who answered Maybe.""" - return self.get_rsvp_state_csv(state=RSVP_STATUS.MAYBE) + return self.get_rsvp_state_csv(RsvpStateEnum.MAYBE) @route('save', methods=['POST']) @requires_login diff --git a/funnel/views/project_sponsor.py b/funnel/views/project_sponsor.py index 1360d3dc4..a64456449 100644 --- a/funnel/views/project_sponsor.py +++ b/funnel/views/project_sponsor.py @@ -9,11 +9,11 @@ from baseframe import _ from baseframe.forms import Form from baseframe.forms.auto import ConfirmDeleteForm -from coaster.auth import current_auth from coaster.utils import getbool from coaster.views import ModelView, UrlChangeCheck, UrlForView, requestform, route from .. import app +from ..auth import current_auth from ..forms import ProjectSponsorForm from ..models import Account, Project, ProjectSponsorMembership, db, sa_orm from ..typing import ReturnView diff --git a/funnel/views/proposal.py b/funnel/views/proposal.py index 5e48b89ce..3f809d81a 100644 --- a/funnel/views/proposal.py +++ b/funnel/views/proposal.py @@ -6,7 +6,6 @@ from baseframe import _, __ from baseframe.forms import Form, render_delete_sqla, render_form, render_template -from coaster.auth import current_auth from coaster.utils import getbool, make_name from coaster.views import ( ModelView, @@ -19,6 +18,7 @@ ) from .. import app +from ..auth import current_auth from ..forms import ( ProposalFeaturedForm, ProposalForm, @@ -72,9 +72,9 @@ class ProjectProposalView(ProjectViewBase): @route('sub/new', methods=['GET', 'POST']) @route('proposals/new', methods=['GET', 'POST']) @requires_login - @render_with('submission_form.html.jinja2') @requires_roles({'reader'}) @requires_user_not_spammy() + @render_with('submission_form.html.jinja2') def new_proposal(self) -> ReturnRenderWith: # This along with the `reader` role makes it possible for # anyone to submit a proposal if the CFP is open. diff --git a/funnel/views/schedule.py b/funnel/views/schedule.py index 82bc496a7..cc3058801 100644 --- a/funnel/views/schedule.py +++ b/funnel/views/schedule.py @@ -5,12 +5,12 @@ from collections import defaultdict from datetime import timedelta from types import SimpleNamespace -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from flask import Response, current_app, json from icalendar import Alarm, Calendar, Event, vCalAddress, vText from pytz import utc -from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.exc import NoResultFound from baseframe import _, localize_timezone from coaster.utils import utcnow @@ -69,7 +69,9 @@ def session_data( def session_list_data( - sessions: list[Session], with_modal_url=False, with_delete_url=False + sessions: list[Session], + with_modal_url: str | None = None, + with_delete_url: bool = False, ): return [ session_data(session, with_modal_url, with_delete_url) for session in sessions @@ -115,7 +117,7 @@ def schedule_data( def schedule_ical( project: Project, rsvp: Rsvp | None = None, future_only: bool = False -): +) -> bytes: cal = Calendar() cal.add('prodid', "-//HasGeek//NONSGML Funnel//EN") cal.add('version', '2.0') @@ -130,7 +132,7 @@ def schedule_ical( cal.add('x-published-ttl', 'PT12H') now = utcnow() for session in project.scheduled_sessions: - if not future_only or session.end_at > now: + if not future_only or (session.end_at is not None and session.end_at > now): cal.add_component(session_ical(session, rsvp)) if not project.scheduled_sessions and project.start_at: cal.add_component( @@ -291,10 +293,12 @@ def edit_schedule(self) -> ReturnRenderWith: 'project': self.obj, 'proposals': proposals, 'from_date': ( - self.obj.start_at_localized.isoformat() if self.obj.start_at else None + start_at.isoformat() + if (start_at := self.obj.start_at_localized) + else None ), 'to_date': ( - self.obj.end_at_localized.isoformat() if self.obj.end_at else None + end_at.isoformat() if (end_at := self.obj.end_at_localized) else None ), 'timezone': self.obj.timezone.zone, 'venues': [venue.current_access() for venue in self.obj.venues], @@ -400,6 +404,9 @@ def updates(self) -> ReturnRenderWith: .first() ) if current_session is not None: + if TYPE_CHECKING: + assert current_session.start_at is not None # nosec B101 + assert current_session.end_at is not None # nosec B101 current_session.start_at = localize_date( current_session.start_at, to_tz=self.obj.venue.project.timezone ) @@ -408,14 +415,18 @@ def updates(self) -> ReturnRenderWith: ) nextdiff = None if next_session is not None: + if TYPE_CHECKING: + assert next_session.start_at is not None # nosec B101 + assert next_session.end_at is not None # nosec B101 next_session.start_at = localize_date( next_session.start_at, to_tz=self.obj.venue.project.timezone ) next_session.end_at = localize_date( next_session.end_at, to_tz=self.obj.venue.project.timezone ) - nextdiff = next_session.start_at.date() - now.date() - nextdiff = nextdiff.total_seconds() / 86400 + nextdiff = ( + next_session.start_at.date() - now.date() + ).total_seconds() / 86400 return { 'room': self.obj, 'current_session': current_session, diff --git a/funnel/views/search.py b/funnel/views/search.py index 3bfa89659..86bd71765 100644 --- a/funnel/views/search.py +++ b/funnel/views/search.py @@ -4,7 +4,7 @@ import re from html import unescape as html_unescape -from typing import Any, TypedDict, TypeVar +from typing import Any, ClassVar, Generic, TypedDict, TypeVar from urllib.parse import quote as urlquote from flask import request, url_for @@ -19,11 +19,11 @@ Account, Comment, Commentset, + ModelSearchProtocol, Project, Proposal, ProposalMembership, Query, - SearchModelUnion, Session, Update, db, @@ -39,6 +39,7 @@ # --- Definitions ---------------------------------------------------------------------- _Q = TypeVar('_Q', bound=Query) +_ST = TypeVar('_ST', bound=ModelSearchProtocol) # PostgreSQL ts_headline markers @@ -61,19 +62,19 @@ # --- Search provider types ------------------------------------------------------------ -class SearchProvider: +class SearchProvider(Generic[_ST]): """Base class for search providers.""" #: Label to use in UI label: str #: Model to query against - model: type[SearchModelUnion] + model: type[_ST] #: Does this model have a title column? - has_title: bool = True + has_title: ClassVar[bool] = True @property def regconfig(self) -> str: - """Return PostgreSQL regconfig language, defaulting to English.""" + """Return PostgreSQL `regconfig` language, defaulting to English.""" return self.model.search_vector.type.options.get('regconfig', 'english') @property @@ -83,7 +84,7 @@ def title_column(self) -> sa.ColumnElement[str]: # That makes this return value incorrect, but here we ignore the error as # class:`CommentSearch` explicitly overrides :meth:`hltitle_column`, and that is # the only place this property is accessed - return self.model.title # type: ignore[return-value] + return self.model.title # type: ignore[return-value] # FIXME @property def hltext(self) -> sa.ColumnElement[str]: @@ -145,10 +146,10 @@ def all_query(self, tsquery: sa.Function) -> Query: def all_count(self, tsquery: sa.Function) -> int: """Return count of results for :meth:`all_query`.""" - return self.all_query(tsquery).options(sa_orm.load_only(self.model.id)).count() + return self.all_query(tsquery).options(sa_orm.load_only(self.model.id_)).count() -class SearchInAccountProvider(SearchProvider): +class SearchInAccountProvider(SearchProvider[_ST]): """Base class for search providers that support searching in an account.""" def account_query(self, tsquery: sa.Function, account: Account) -> Query: @@ -159,12 +160,12 @@ def account_count(self, tsquery: sa.Function, account: Account) -> int: """Return count of results for :meth:`account_query`.""" return ( self.account_query(tsquery, account) - .options(sa_orm.load_only(self.model.id)) + .options(sa_orm.load_only(self.model.id_)) .count() ) -class SearchInProjectProvider(SearchInAccountProvider): +class SearchInProjectProvider(SearchInAccountProvider[_ST]): """Base class for search providers that support searching in a project.""" def project_query(self, tsquery: sa.Function, project: Project) -> Query: @@ -175,7 +176,7 @@ def project_count(self, tsquery: sa.Function, project: Project) -> int: """Return count of results for :meth:`project_query`.""" return ( self.project_query(tsquery, project) - .options(sa_orm.load_only(self.model.id)) + .options(sa_orm.load_only(self.model.id_)) .count() ) @@ -230,7 +231,7 @@ def all_query(self, tsquery: sa.Function) -> Query[Project]: def all_count(self, tsquery: sa.Function) -> int: """Return count of matching projects across the entire site.""" return ( - db.session.query(sa.func.count('*')) + db.session.query(sa.func.count(sa.text('*'))) .select_from(Project) .join(Account, Project.account) .filter( diff --git a/funnel/views/session.py b/funnel/views/session.py index e2a658c1f..7b352e819 100644 --- a/funnel/views/session.py +++ b/funnel/views/session.py @@ -2,16 +2,16 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING from flask import render_template, request from baseframe import _ -from coaster.auth import current_auth from coaster.sqlalchemy import failsafe_add from coaster.views import ModelView, UrlChangeCheck, UrlForView, requires_roles, route from .. import app +from ..auth import current_auth from ..forms import SavedProjectForm, SavedSessionForm, SessionForm from ..models import Account, Project, Proposal, SavedSession, Session, db from ..proxies import request_wants @@ -61,7 +61,7 @@ def session_edit( else: form = SessionForm() if proposal is not None: - form.description.data = proposal.body + form.description.data = str(proposal.body) form.speaker.data = proposal.first_user.fullname form.title.data = proposal.title @@ -92,7 +92,8 @@ def session_edit( else: db.session.add(session) db.session.commit() - session = cast(Session, session) # Tell mypy session is not None + if TYPE_CHECKING: # FIXME: Needed for Mypy in pre-commit only, unclear why + assert session is not None # nosec B101 session.project.update_schedule_timestamps() db.session.commit() if request_wants.html_in_json: @@ -177,13 +178,13 @@ def view(self) -> ReturnView: datasets=('without_parent', 'related') ), from_date=( - self.obj.project.start_at_localized.isoformat() - if self.obj.project.start_at + start_at.isoformat() + if (start_at := self.obj.project.start_at_localized) else None ), to_date=( - self.obj.project.end_at_localized.isoformat() - if self.obj.project.end_at + end_at.isoformat() + if (end_at := self.obj.project.end_at_localized) else None ), active_session=session_data(self.obj, with_modal_url='view'), diff --git a/funnel/views/siteadmin.py b/funnel/views/siteadmin.py index 6316c8373..82d037c47 100644 --- a/funnel/views/siteadmin.py +++ b/funnel/views/siteadmin.py @@ -21,10 +21,10 @@ from baseframe import _ from baseframe.forms import Form -from coaster.auth import current_auth from coaster.views import ClassView, render_with, requestargs, route from .. import app +from ..auth import current_auth from ..forms import ModeratorReportForm from ..models import ( MODERATOR_REPORT_TYPE, @@ -215,7 +215,7 @@ def dashboard_data_users_by_client(self) -> ReturnView: auth_client_login_session.c.login_session_id == LoginSession.id, Account.state.ACTIVE, auth_client_login_session.c.accessed_at - >= sa.func.utcnow() - sa.func.cast(interval, INTERVAL), + >= sa.func.utcnow() - sa.func.cast(sa.text(interval), INTERVAL), ) .group_by( auth_client_login_session.c.auth_client_id, LoginSession.account_id @@ -310,11 +310,7 @@ def comments( 'comment_spam_form': Form(), } - @route( - 'comments/markspam', - endpoint='siteadmin_comments_spam', - methods=['POST'], - ) + @route('comments/markspam', endpoint='siteadmin_comments_spam', methods=['POST']) @requires_comment_moderator def markspam(self) -> ReturnResponse: """Mark comments as spam.""" diff --git a/funnel/views/ticket_participant.py b/funnel/views/ticket_participant.py index ad9757895..84f5509f0 100644 --- a/funnel/views/ticket_participant.py +++ b/funnel/views/ticket_participant.py @@ -2,7 +2,7 @@ from __future__ import annotations -from flask import flash, request, url_for +from flask import abort, flash, request, url_for from sqlalchemy.exc import IntegrityError from baseframe import _, forms @@ -260,6 +260,8 @@ def checkin(self) -> ReturnView: ticket_participant_ids = request.form.getlist('puuid_b58') for ticket_participant_id in ticket_participant_ids: attendee = TicketEventParticipant.get(self.obj, ticket_participant_id) + if attendee is None: + abort(404) attendee.checked_in = bool(checked_in) db.session.commit() if request_wants.json: diff --git a/funnel/views/update.py b/funnel/views/update.py index 99328566a..6202551b0 100644 --- a/funnel/views/update.py +++ b/funnel/views/update.py @@ -6,7 +6,6 @@ from baseframe import _, forms from baseframe.forms import render_form -from coaster.auth import current_auth from coaster.utils import make_name from coaster.views import ( ModelView, @@ -18,6 +17,7 @@ ) from .. import app +from ..auth import current_auth from ..forms import SavedProjectForm, UpdateForm from ..models import Account, NewUpdateNotification, Project, Update, db from ..typing import ReturnRenderWith, ReturnView diff --git a/funnel/views/video.py b/funnel/views/video.py index 8978018e8..7dfe58ba2 100644 --- a/funnel/views/video.py +++ b/funnel/views/video.py @@ -57,7 +57,7 @@ def set_video_cache(obj: VideoMixin, data: VideoData, exists: bool = True) -> No copied_data['uploaded_at'] = cast( datetime, copied_data['uploaded_at'] ).isoformat() - redis_store.hmset(cache_key, copied_data) + redis_store.hset(cache_key, mapping=copied_data) # if video exists at source, cache for 2 days, if not, for 6 hours hours_to_cache = 2 * 24 if exists else 6 diff --git a/migrations/versions/047ebdac558b_complement_email_md5sum_with_blake2b.py b/migrations/versions/047ebdac558b_complement_email_md5sum_with_blake2b.py index 4c9045d76..45383fdd2 100644 --- a/migrations/versions/047ebdac558b_complement_email_md5sum_with_blake2b.py +++ b/migrations/versions/047ebdac558b_complement_email_md5sum_with_blake2b.py @@ -59,7 +59,7 @@ def upgrade() -> None: # Add blake2b column to UserEmail op.add_column('user_email', sa.Column('blake2b', sa.LargeBinary(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user_email)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(user_email)) progress = get_progressbar("Emails", count) progress.start() items = conn.execute(sa.select(user_email.c.id, user_email.c.email)) @@ -85,7 +85,9 @@ def upgrade() -> None: op.add_column( 'user_email_claim', sa.Column('blake2b', sa.LargeBinary(), nullable=True) ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user_email_claim)) + count = conn.scalar( + sa.select(sa.func.count(sa.text('*'))).select_from(user_email_claim) + ) progress = get_progressbar("Email claims", count) progress.start() items = conn.execute(sa.select(user_email_claim.c.id, user_email_claim.c.email)) diff --git a/migrations/versions/1c9cbf3a1e5e_add_commenset_memberships.py b/migrations/versions/1c9cbf3a1e5e_add_commenset_memberships.py index 2bcde53c8..f612ca46a 100644 --- a/migrations/versions/1c9cbf3a1e5e_add_commenset_memberships.py +++ b/migrations/versions/1c9cbf3a1e5e_add_commenset_memberships.py @@ -129,7 +129,7 @@ def downgrade(engine_name=''): def upgrade_(): conn = op.get_bind() - count = conn.scalar(sa.select(sa.func.count('*')).select_from(project)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(project)) progress = get_progressbar("Projects", count) progress.start() @@ -139,7 +139,7 @@ def upgrade_(): for counter, project_item in enumerate(projects): # Create membership for existing RSVP rsvp_count = conn.scalar( - sa.select(sa.func.count('*')) + sa.select(sa.func.count(sa.text('*'))) .where(rsvp.c.project_id == project_item.id) .select_from(rsvp) ) @@ -157,7 +157,7 @@ def upgrade_(): for rsvp_item in rsvps: existing_counter = conn.scalar( - sa.select(sa.func.count('*')) + sa.select(sa.func.count(sa.text('*'))) .where( commentset_membership.c.commentset_id == project_item.commentset_id ) @@ -196,7 +196,7 @@ def upgrade_(): for crew in crews: existing_counter = conn.scalar( - sa.select(sa.func.count('*')) + sa.select(sa.func.count(sa.text('*'))) .where( commentset_membership.c.commentset_id == project_item.commentset_id ) @@ -225,7 +225,9 @@ def upgrade_(): progress.finish() # Create commentset membership for existing proposal memberships - count = conn.scalar(sa.select(sa.func.count('*')).select_from(proposal_membership)) + count = conn.scalar( + sa.select(sa.func.count(sa.text('*'))).select_from(proposal_membership) + ) progress = get_progressbar("Proposals", count) progress.start() @@ -243,7 +245,7 @@ def upgrade_(): ) for counter, proposal_item in enumerate(proposals): existing_counter = conn.scalar( - sa.select(sa.func.count('*')) + sa.select(sa.func.count(sa.text('*'))) .where(commentset_membership.c.commentset_id == proposal_item.commentset_id) .where(commentset_membership.c.user_id == proposal_item.user_id) .where(commentset_membership.c.revoked_at.is_(None)) @@ -273,7 +275,7 @@ def upgrade_(): def downgrade_(): conn = op.get_bind() - count = conn.scalar(sa.select(sa.func.count('*')).select_from(project)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(project)) progress = get_progressbar("Projects", count) progress.start() @@ -282,7 +284,7 @@ def downgrade_(): commentset_memberships = conn.execute( sa.select(commentset_membership.c.id) .where(commentset_membership.c.commentset_id == project_item.commentset_id) - .where(commentset_membership.c.revoked_at is None) + .where(commentset_membership.c.revoked_at.is_(None)) .select_from(commentset_membership) ) for membership_item in commentset_memberships: @@ -294,7 +296,7 @@ def downgrade_(): progress.update(counter) progress.finish() - count = conn.scalar(sa.select(sa.func.count('*')).select_from(proposal)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(proposal)) progress = get_progressbar("Proposals", count) progress.start() @@ -307,7 +309,7 @@ def downgrade_(): commentset_memberships = conn.execute( sa.select(commentset_membership.c.id) .where(commentset_membership.c.commentset_id == proposal_item.commentset_id) - .where(commentset_membership.c.revoked_at is None) + .where(commentset_membership.c.revoked_at.is_(None)) .select_from(commentset_membership) ) for membership_item in commentset_memberships: diff --git a/migrations/versions/284c10efdbce_deprecate_session_speaker_bio.py b/migrations/versions/284c10efdbce_deprecate_session_speaker_bio.py index 149ff54a7..e61746248 100644 --- a/migrations/versions/284c10efdbce_deprecate_session_speaker_bio.py +++ b/migrations/versions/284c10efdbce_deprecate_session_speaker_bio.py @@ -58,7 +58,7 @@ def session_description(row): def upgrade() -> None: conn = op.get_bind() - count = conn.scalar(sa.select(sa.func.count('*')).select_from(session)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(session)) progress = get_progressbar("Sessions", count) progress.start() items = conn.execute(session.select()) diff --git a/migrations/versions/2cbfbcca4737_uuid_columns_for_proposal_and_session.py b/migrations/versions/2cbfbcca4737_uuid_columns_for_proposal_and_session.py index 227e8ab52..085c9ed92 100644 --- a/migrations/versions/2cbfbcca4737_uuid_columns_for_proposal_and_session.py +++ b/migrations/versions/2cbfbcca4737_uuid_columns_for_proposal_and_session.py @@ -43,7 +43,7 @@ def upgrade() -> None: conn = op.get_bind() op.add_column('proposal', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(proposal)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(proposal)) progress = get_progressbar("Proposals", count) progress.start() items = conn.execute(sa.select(proposal.c.id)) @@ -57,7 +57,7 @@ def upgrade() -> None: op.create_unique_constraint('proposal_uuid_key', 'proposal', ['uuid']) op.add_column('session', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(session)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(session)) progress = get_progressbar("Sessions", count) progress.start() items = conn.execute(sa.select(session.c.id)) diff --git a/migrations/versions/321b11b6a413_add_participant_uuid_field.py b/migrations/versions/321b11b6a413_add_participant_uuid_field.py index 5df001c4e..feac414db 100644 --- a/migrations/versions/321b11b6a413_add_participant_uuid_field.py +++ b/migrations/versions/321b11b6a413_add_participant_uuid_field.py @@ -44,7 +44,7 @@ def upgrade() -> None: op.add_column('participant', sa.Column('uuid', sa.Uuid(), nullable=True)) # migrate past participants - count = conn.scalar(sa.select(sa.func.count('*')).select_from(participant)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(participant)) progress = get_progressbar("Participants", count) progress.start() items = conn.execute(sa.select(participant.c.id)) diff --git a/migrations/versions/331a4250aa4b_account_merges_user_organization_and_.py b/migrations/versions/331a4250aa4b_account_merges_user_organization_and_.py index 1459a7ba2..16573aade 100644 --- a/migrations/versions/331a4250aa4b_account_merges_user_organization_and_.py +++ b/migrations/versions/331a4250aa4b_account_merges_user_organization_and_.py @@ -152,7 +152,7 @@ def __post_init__(self): # If not renaming (new is None), use old name from `self.old` rn.table_name = self.future_name or self.current_name rn.old_table_name = self.current_name - rn.symbol = symbol + rn.symbol = symbol # type: ignore[assignment] def upgrade(self) -> None: """Do the rename.""" diff --git a/migrations/versions/5f1ab3e04f73_drop_support_for_128_bit_blake2b_hash_.py b/migrations/versions/5f1ab3e04f73_drop_support_for_128_bit_blake2b_hash_.py index 25cdb531c..eee61ac8d 100644 --- a/migrations/versions/5f1ab3e04f73_drop_support_for_128_bit_blake2b_hash_.py +++ b/migrations/versions/5f1ab3e04f73_drop_support_for_128_bit_blake2b_hash_.py @@ -63,7 +63,9 @@ def downgrade() -> None: sa.Column('blake2b', postgresql.BYTEA(), autoincrement=False, nullable=True), ) # Recalculate blake2b hashes - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user_email_claim)) + count = conn.scalar( + sa.select(sa.func.count(sa.text('*'))).select_from(user_email_claim) + ) progress = get_progressbar("Email claims", count) progress.start() items = conn.execute( diff --git a/migrations/versions/63c44675b6cd_migrate_to_phonenumber.py b/migrations/versions/63c44675b6cd_migrate_to_phonenumber.py index e5e8afd1b..a6c634290 100644 --- a/migrations/versions/63c44675b6cd_migrate_to_phonenumber.py +++ b/migrations/versions/63c44675b6cd_migrate_to_phonenumber.py @@ -110,7 +110,7 @@ def upgrade_() -> None: op.add_column( 'user_phone', sa.Column('phone_number_id', sa.Integer(), nullable=True) ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user_phone)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(user_phone)) items = conn.execute( sa.select( user_phone.c.id, @@ -148,10 +148,8 @@ def upgrade_() -> None: allow_sm=False, ) .returning(phone_number.c.id) - ).fetchone()[ - 0 - ] # type: ignore[index] - + ).first() + assert pn_id is not None # nosec B101 conn.execute( user_phone.update() .where(user_phone.c.id == item.id) @@ -175,7 +173,7 @@ def upgrade_() -> None: op.drop_column('user_phone', 'gets_text') # --- SmsMessage ------------------------------------------------------------------- - # Remove rows with no transactionid, as the data is not validated in any way + # Remove rows with no `transactionid`, as the data is not validated in any way conn.execute(sa.delete(sms_message).where(sms_message.c.transactionid.is_(None))) op.add_column( 'sms_message', sa.Column('phone_number_id', sa.Integer(), nullable=True) @@ -183,7 +181,7 @@ def upgrade_() -> None: rows_to_delete = set() - count = conn.scalar(sa.select(sa.func.count('*')).select_from(sms_message)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(sms_message)) items = conn.execute( sa.select( sms_message.c.id, @@ -279,9 +277,8 @@ def upgrade_() -> None: **timestamps, ) .returning(phone_number.c.id) - ).fetchone()[ - 0 - ] # type: ignore[index] + ).first() + assert pn_id is not None # nosec B101 conn.execute( sms_message.update() .where(sms_message.c.id == item.id) @@ -328,7 +325,7 @@ def downgrade_() -> None: nullable=True, ), ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(sms_message)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(sms_message)) items = conn.execute( sa.select(sms_message.c.id, phone_number.c.number).where( sms_message.c.phone_number_id == phone_number.c.id @@ -362,7 +359,7 @@ def downgrade_() -> None: op.add_column( 'user_phone', sa.Column('phone', sa.TEXT(), autoincrement=False, nullable=True) ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user_phone)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(user_phone)) items = conn.execute( sa.select(user_phone.c.id, phone_number.c.number).where( user_phone.c.phone_number_id == phone_number.c.id diff --git a/migrations/versions/69c2ced88981_team_org_uuid.py b/migrations/versions/69c2ced88981_team_org_uuid.py index 42ad39f46..7feb122dc 100644 --- a/migrations/versions/69c2ced88981_team_org_uuid.py +++ b/migrations/versions/69c2ced88981_team_org_uuid.py @@ -46,7 +46,7 @@ def upgrade() -> None: conn = op.get_bind() op.add_column('team', sa.Column('org_uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(team)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(team)) progress = get_progressbar("Teams", count) progress.start() items = conn.execute(sa.select(team.c.id, team.c.orgid)) @@ -70,7 +70,7 @@ def downgrade() -> None: op.add_column( 'team', sa.Column('orgid', sa.String(22), autoincrement=False, nullable=True) ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(team)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(team)) progress = get_progressbar("Teams", count) progress.start() items = conn.execute(sa.select(team.c.id, team.c.org_uuid)) diff --git a/migrations/versions/7f8114c73092_add_rsvp_uuid.py b/migrations/versions/7f8114c73092_add_rsvp_uuid.py index 4836eb1a2..7fced4679 100644 --- a/migrations/versions/7f8114c73092_add_rsvp_uuid.py +++ b/migrations/versions/7f8114c73092_add_rsvp_uuid.py @@ -49,7 +49,7 @@ def upgrade() -> None: conn = op.get_bind() op.add_column('rsvp', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(rsvp)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(rsvp)) progress = get_progressbar("Rsvps", count) progress.start() diff --git a/migrations/versions/887db555cca9_adding_uuid_to_commentset.py b/migrations/versions/887db555cca9_adding_uuid_to_commentset.py index 7702f2e50..3e4291924 100644 --- a/migrations/versions/887db555cca9_adding_uuid_to_commentset.py +++ b/migrations/versions/887db555cca9_adding_uuid_to_commentset.py @@ -45,7 +45,7 @@ def upgrade() -> None: op.add_column('commentset', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(commentset)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(commentset)) progress = get_progressbar("Commentsets", count) progress.start() items = conn.execute(sa.select(commentset.c.id)) diff --git a/migrations/versions/ad5013552ec6_simplify_proposal_fields.py b/migrations/versions/ad5013552ec6_simplify_proposal_fields.py index 1551e0219..4fa98c8a6 100644 --- a/migrations/versions/ad5013552ec6_simplify_proposal_fields.py +++ b/migrations/versions/ad5013552ec6_simplify_proposal_fields.py @@ -87,7 +87,7 @@ def upgrade() -> None: op.add_column('proposal', sa.Column('body_html', sa.UnicodeText(), nullable=True)) op.add_column('proposal', sa.Column('description', sa.Unicode(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(proposal)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(proposal)) progress = get_progressbar("Proposals", count) progress.start() items = conn.execute(proposal.select()) diff --git a/migrations/versions/ae075a249493_migrate_email_addresses.py b/migrations/versions/ae075a249493_migrate_email_addresses.py index a6ea10cff..dd639e74f 100644 --- a/migrations/versions/ae075a249493_migrate_email_addresses.py +++ b/migrations/versions/ae075a249493_migrate_email_addresses.py @@ -167,7 +167,7 @@ def upgrade() -> None: 'user_email', sa.Column('email_address_id', sa.Integer(), nullable=True) ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user_email)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(user_email)) progress = get_progressbar("Emails", count) progress.start() items = conn.execute( @@ -259,7 +259,9 @@ def upgrade() -> None: ondelete='SET NULL', ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user_email_claim)) + count = conn.scalar( + sa.select(sa.func.count(sa.text('*'))).select_from(user_email_claim) + ) progress = get_progressbar("Email claims", count) progress.start() items = conn.execute( @@ -348,7 +350,7 @@ def upgrade() -> None: ) count = conn.scalar( - sa.select(sa.func.count('*')) + sa.select(sa.func.count(sa.text('*'))) .select_from(proposal) .where(proposal.c.email.is_not(None)) ) @@ -413,7 +415,7 @@ def downgrade() -> None: ) count = conn.scalar( - sa.select(sa.func.count('*')) + sa.select(sa.func.count(sa.text('*'))) .select_from(proposal) .where(proposal.c.email_address_id.is_not(None)) ) @@ -449,7 +451,9 @@ def downgrade() -> None: sa.Column('email', sa.VARCHAR(length=254), autoincrement=False, nullable=True), ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user_email_claim)) + count = conn.scalar( + sa.select(sa.func.count(sa.text('*'))).select_from(user_email_claim) + ) progress = get_progressbar("Email claims", count) progress.start() items = conn.execute( @@ -519,7 +523,7 @@ def downgrade() -> None: sa.Column('blake2b', postgresql.BYTEA(), autoincrement=False, nullable=True), ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user_email)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(user_email)) progress = get_progressbar("Emails", count) progress.start() items = conn.execute( diff --git a/migrations/versions/aebd5a9e5af1_project_timestamps.py b/migrations/versions/aebd5a9e5af1_project_timestamps.py index 804c56f03..5e9e44797 100644 --- a/migrations/versions/aebd5a9e5af1_project_timestamps.py +++ b/migrations/versions/aebd5a9e5af1_project_timestamps.py @@ -88,7 +88,7 @@ def upgrade() -> None: # Update project start_at/end_at timestamps where sessions exist conn = op.get_bind() - count = conn.scalar(sa.select(sa.func.count('*')).select_from(project)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(project)) progress = get_progressbar("Projects", count) progress.start() project_ids = conn.execute(sa.select(project.c.id)) diff --git a/migrations/versions/b34aa62af7fc_uuid_columns_for_project_profile_user_team.py b/migrations/versions/b34aa62af7fc_uuid_columns_for_project_profile_user_team.py index 1ecfc660c..0cb59af67 100644 --- a/migrations/versions/b34aa62af7fc_uuid_columns_for_project_profile_user_team.py +++ b/migrations/versions/b34aa62af7fc_uuid_columns_for_project_profile_user_team.py @@ -65,7 +65,7 @@ def upgrade() -> None: # --- Project op.add_column('project', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(project)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(project)) progress = get_progressbar("Projects", count) progress.start() items = conn.execute(sa.select(project.c.id)) @@ -80,7 +80,7 @@ def upgrade() -> None: # --- Profile op.add_column('profile', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(profile)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(profile)) progress = get_progressbar("Profiles", count) progress.start() items = conn.execute(sa.select(profile.c.id, profile.c.userid)) @@ -99,7 +99,7 @@ def upgrade() -> None: # --- Team op.add_column('team', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(team)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(team)) progress = get_progressbar("Teams", count) progress.start() items = conn.execute(sa.select(team.c.id, team.c.userid)) @@ -118,7 +118,7 @@ def upgrade() -> None: # --- User op.add_column('user', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(user)) progress = get_progressbar("Users", count) progress.start() items = conn.execute(sa.select(user.c.id, user.c.userid)) @@ -142,7 +142,7 @@ def downgrade() -> None: # --- User op.add_column('user', sa.Column('userid', sa.String(22), nullable=True)) op.create_unique_constraint('user_userid_key', 'user', ['userid']) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(user)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(user)) progress = get_progressbar("Users", count) progress.start() items = conn.execute(sa.select(user.c.id, user.c.uuid)) @@ -161,7 +161,7 @@ def downgrade() -> None: # --- Team op.add_column('team', sa.Column('userid', sa.String(22), nullable=True)) op.create_unique_constraint('team_userid_key', 'team', ['userid']) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(team)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(team)) progress = get_progressbar("Teams", count) progress.start() items = conn.execute(sa.select(team.c.id, team.c.uuid)) @@ -180,7 +180,7 @@ def downgrade() -> None: # --- Profile op.add_column('profile', sa.Column('userid', sa.String(22), nullable=True)) op.create_unique_constraint('profile_userid_key', 'profile', ['userid']) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(profile)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(profile)) progress = get_progressbar("Profiles", count) progress.start() items = conn.execute(sa.select(profile.c.id, profile.c.uuid)) diff --git a/migrations/versions/c3069d33419a_comment_uuid_field.py b/migrations/versions/c3069d33419a_comment_uuid_field.py index b873631f1..34fbd17a1 100644 --- a/migrations/versions/c3069d33419a_comment_uuid_field.py +++ b/migrations/versions/c3069d33419a_comment_uuid_field.py @@ -41,7 +41,7 @@ def upgrade() -> None: op.add_column('comment', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(comment)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(comment)) progress = get_progressbar("Comments", count) progress.start() items = conn.execute(sa.select(comment.c.id)) diff --git a/migrations/versions/ca578c1b82e8_populate_usersession_geonameid_from_ip_.py b/migrations/versions/ca578c1b82e8_populate_usersession_geonameid_from_ip_.py index 9b7af1fc7..5afb523fe 100644 --- a/migrations/versions/ca578c1b82e8_populate_usersession_geonameid_from_ip_.py +++ b/migrations/versions/ca578c1b82e8_populate_usersession_geonameid_from_ip_.py @@ -72,7 +72,7 @@ def upgrade() -> None: if geoip_city is not None or geoip_asn is not None: conn = op.get_bind() count = conn.scalar( - sa.select(sa.func.count('*')) + sa.select(sa.func.count(sa.text('*'))) .select_from(user_session) .where( sa.or_( diff --git a/migrations/versions/d79beb04a529_remove_incorrect_nullable_flag.py b/migrations/versions/d79beb04a529_remove_incorrect_nullable_flag.py new file mode 100644 index 000000000..e37afe34d --- /dev/null +++ b/migrations/versions/d79beb04a529_remove_incorrect_nullable_flag.py @@ -0,0 +1,40 @@ +"""Remove incorrect nullable flag. + +Revision ID: d79beb04a529 +Revises: f0ed25eed4bc +Create Date: 2023-12-21 00:55:05.801455 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'd79beb04a529' +down_revision: str = 'f0ed25eed4bc' +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade(engine_name: str = '') -> None: + """Upgrade all databases.""" + # Do not modify. Edit `upgrade_` instead + globals().get(f'upgrade_{engine_name}', lambda: None)() + + +def downgrade(engine_name: str = '') -> None: + """Downgrade all databases.""" + # Do not modify. Edit `downgrade_` instead + globals().get(f'downgrade_{engine_name}', lambda: None)() + + +def upgrade_() -> None: + """Upgrade default database.""" + with op.batch_alter_table('auth_client', schema=None) as batch_op: + batch_op.alter_column('account_id', existing_type=sa.INTEGER(), nullable=False) + + +def downgrade_() -> None: + """Downgrade default database.""" + with op.batch_alter_table('auth_client', schema=None) as batch_op: + batch_op.alter_column('account_id', existing_type=sa.INTEGER(), nullable=True) diff --git a/migrations/versions/e2b28adfa135_add_proposal_suuid_redirect.py b/migrations/versions/e2b28adfa135_add_proposal_suuid_redirect.py index c72feedf5..5b67d654d 100644 --- a/migrations/versions/e2b28adfa135_add_proposal_suuid_redirect.py +++ b/migrations/versions/e2b28adfa135_add_proposal_suuid_redirect.py @@ -74,7 +74,7 @@ def upgrade() -> None: ) conn = op.get_bind() - count = conn.scalar(sa.select(sa.func.count('*')).select_from(proposal)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(proposal)) progress = get_progressbar("Proposals", count) progress.start() items = conn.execute(sa.select(proposal.c.id, proposal.c.uuid)) diff --git a/migrations/versions/e2be4ab896d3_migrate_old_labels.py b/migrations/versions/e2be4ab896d3_migrate_old_labels.py index 8eadf602d..830676efb 100644 --- a/migrations/versions/e2be4ab896d3_migrate_old_labels.py +++ b/migrations/versions/e2be4ab896d3_migrate_old_labels.py @@ -71,7 +71,7 @@ def upgrade() -> None: sec_count = cast( int, conn.scalar( - sa.select(sa.func.count('*')) + sa.select(sa.func.count(sa.text('*'))) .select_from(section) .where(section.c.project_id == proj.id) ), @@ -236,7 +236,7 @@ def upgrade() -> None: duplicate_count = cast( int, conn.scalar( - sa.select(sa.func.count('*')) + sa.select(sa.func.count(sa.text('*'))) .select_from(label) .where(label.c.name == st_name) .where(label.c.project_id == proj.id) diff --git a/migrations/versions/e3b3ccbca3b9_move_participant_to_emailaddressmixin.py b/migrations/versions/e3b3ccbca3b9_move_participant_to_emailaddressmixin.py index 906475372..38e9cbe06 100644 --- a/migrations/versions/e3b3ccbca3b9_move_participant_to_emailaddressmixin.py +++ b/migrations/versions/e3b3ccbca3b9_move_participant_to_emailaddressmixin.py @@ -139,7 +139,7 @@ def upgrade() -> None: ondelete='SET NULL', ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(participant)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(participant)) progress = get_progressbar("Participants", count) progress.start() items = conn.execute( @@ -244,7 +244,7 @@ def downgrade() -> None: 'participant', sa.Column('email', sa.VARCHAR(length=254), autoincrement=False, nullable=True), ) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(participant)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(participant)) progress = get_progressbar("Participants", count) progress.start() items = conn.execute( diff --git a/migrations/versions/eec2fad0f3e9_venue_uuid_field.py b/migrations/versions/eec2fad0f3e9_venue_uuid_field.py index f9d951ad7..366d6b5b4 100644 --- a/migrations/versions/eec2fad0f3e9_venue_uuid_field.py +++ b/migrations/versions/eec2fad0f3e9_venue_uuid_field.py @@ -40,7 +40,7 @@ def upgrade() -> None: conn = op.get_bind() op.add_column('venue', sa.Column('uuid', sa.Uuid(), nullable=True)) - count = conn.scalar(sa.select(sa.func.count('*')).select_from(venue)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(venue)) progress = get_progressbar("Venues", count) progress.start() items = conn.execute(sa.select(venue.c.id)) diff --git a/migrations/versions/fa05ebecbc0f_populate_proposalmembership.py b/migrations/versions/fa05ebecbc0f_populate_proposalmembership.py index 025b31446..7bbbe085f 100644 --- a/migrations/versions/fa05ebecbc0f_populate_proposalmembership.py +++ b/migrations/versions/fa05ebecbc0f_populate_proposalmembership.py @@ -87,7 +87,7 @@ def get_progressbar(label, maxval): def upgrade() -> None: # Adapts from `proposal` table to an empty `proposal_membership` table. conn = op.get_bind() - count = conn.scalar(sa.select(sa.func.count('*')).select_from(proposal)) + count = conn.scalar(sa.select(sa.func.count(sa.text('*'))).select_from(proposal)) progress = get_progressbar("Proposals", count) progress.start() diff --git a/package-lock.json b/package-lock.json index d33c92e94..12200f8c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,12 +106,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", "dev": true, "dependencies": { - "@babel/highlight": "^7.22.13", + "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" }, "engines": { @@ -119,30 +119,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", - "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", - "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", + "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.23.0", - "@babel/helpers": "^7.23.2", - "@babel/parser": "^7.23.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.7", + "@babel/parser": "^7.23.6", "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -158,12 +158,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", "dev": true, "dependencies": { - "@babel/types": "^7.23.0", + "@babel/types": "^7.23.6", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -197,14 +197,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", - "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.22.9", - "@babel/helper-validator-option": "^7.22.15", - "browserslist": "^4.21.9", + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -213,17 +213,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz", - "integrity": "sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.7.tgz", + "integrity": "sha512-xCoqR/8+BoNnXOY7RVSgv6X+o7pmT5q1d+gGcRlXYkI+9B31glE4jeejhKVpA04O1AtzOt7OSQ6VYKP5FcRl9g==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-member-expression-to-functions": "^7.22.15", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-member-expression-to-functions": "^7.23.0", "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.9", + "@babel/helper-replace-supers": "^7.22.20", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", "semver": "^6.3.1" @@ -253,9 +253,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.3.tgz", - "integrity": "sha512-WBrLmuPP47n7PNwsZ57pqam6G/RGo1vw/87b0Blc53tZNGZ4x7YvZ6HgQe2vo1W/FR20OgjeZuGXzudPiXHFug==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz", + "integrity": "sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==", "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -327,9 +327,9 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", - "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", @@ -437,9 +437,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", "dev": true, "engines": { "node": ">=6.9.0" @@ -454,9 +454,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", - "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", "dev": true, "engines": { "node": ">=6.9.0" @@ -477,23 +477,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", - "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.7.tgz", + "integrity": "sha512-6AMnjCoC8wjqBzDHkuqpa7jAKwvMo4dC+lr/TFBz+ucfulO1XMpDnwWPGBNwClOKZ8h6xn5N81W/R5OrcKtCbQ==", "dev": true, "dependencies": { "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0" + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", - "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -504,9 +504,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", "bin": { "parser": "bin/babel-parser.js" }, @@ -515,9 +515,9 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.15.tgz", - "integrity": "sha512-FB9iYlz7rURmRJyXRKEnalYPPdn87H5no108cyuQQyMwlpJ2SJtpIUBI27kdTin956pz+LPypkPVPUTlxOmrsg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz", + "integrity": "sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -530,14 +530,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.15.tgz", - "integrity": "sha512-Hyph9LseGvAeeXzikV88bczhsrLrIZqDPxO+sSmAunMPaGrBGhfMWzCPYTtiW9t+HzSE2wtV8e5cc5P6r1xMDQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz", + "integrity": "sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.22.15" + "@babel/plugin-transform-optional-chaining": "^7.23.3" }, "engines": { "node": ">=6.9.0" @@ -546,6 +546,22 @@ "@babel/core": "^7.13.0" } }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.7.tgz", + "integrity": "sha512-LlRT7HgaifEpQA1ZgLVOIJZZFVPWN5iReq/7/JixwBtwcoeVGDBD53ZV28rrsLYOZs1Y/EHhA8N/Z6aazHR8cw==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", @@ -622,9 +638,9 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz", - "integrity": "sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz", + "integrity": "sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -637,9 +653,9 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz", - "integrity": "sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.23.3.tgz", + "integrity": "sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -794,9 +810,9 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz", - "integrity": "sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz", + "integrity": "sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -809,9 +825,9 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.2.tgz", - "integrity": "sha512-BBYVGxbDVHfoeXbOwcagAkOQAm9NxoTdMGfTqghu1GrvadSaw6iW3Je6IcL5PNOw8VwjxqBECXy50/iCQSY/lQ==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.7.tgz", + "integrity": "sha512-PdxEpL71bJp1byMG0va5gwQcXHxuEYC/BgI/e88mGTtohbZN28O5Yit0Plkkm/dBzCF/BxmbNcses1RH1T+urA==", "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.22.20", @@ -827,14 +843,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz", - "integrity": "sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz", + "integrity": "sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.5" + "@babel/helper-remap-async-to-generator": "^7.22.20" }, "engines": { "node": ">=6.9.0" @@ -844,9 +860,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz", - "integrity": "sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz", + "integrity": "sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -859,9 +875,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.0.tgz", - "integrity": "sha512-cOsrbmIOXmf+5YbL99/S49Y3j46k/T16b9ml8bm9lP6N9US5iQ2yBK7gpui1pg0V/WMcXdkfKbTb7HXq9u+v4g==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz", + "integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -874,12 +890,12 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz", - "integrity": "sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz", + "integrity": "sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -890,12 +906,12 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.11.tgz", - "integrity": "sha512-GMM8gGmqI7guS/llMFk1bJDkKfn3v3C4KHK9Yg1ey5qcHcOlKb0QvcMrgzvxo+T03/4szNh5lghY+fEC98Kq9g==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz", + "integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.11", + "@babel/helper-create-class-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-class-static-block": "^7.14.5" }, @@ -907,18 +923,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.15.tgz", - "integrity": "sha512-VbbC3PGjBdE0wAWDdHM9G8Gm977pnYI0XpqMd6LrKISj8/DJXEsWqgRuTYaNE9Bv0JGhTZUzHDlMk18IpOuoqw==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.5.tgz", + "integrity": "sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-optimise-call-expression": "^7.22.5", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.9", + "@babel/helper-replace-supers": "^7.22.20", "@babel/helper-split-export-declaration": "^7.22.6", "globals": "^11.1.0" }, @@ -930,13 +946,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz", - "integrity": "sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz", + "integrity": "sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", - "@babel/template": "^7.22.5" + "@babel/template": "^7.22.15" }, "engines": { "node": ">=6.9.0" @@ -946,9 +962,9 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.0.tgz", - "integrity": "sha512-vaMdgNXFkYrB+8lbgniSYWHsgqK5gjaMNcc84bMIOMRLH0L9AqYq3hwMdvnyqj1OPqea8UtjPEuS/DCenah1wg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz", + "integrity": "sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -961,12 +977,12 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz", - "integrity": "sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz", + "integrity": "sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-create-regexp-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -977,9 +993,9 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz", - "integrity": "sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz", + "integrity": "sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -992,9 +1008,9 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.11.tgz", - "integrity": "sha512-g/21plo58sfteWjaO0ZNVb+uEOkJNjAaHhbejrnBmu011l/eNDScmkbjCC3l4FKb10ViaGU4aOkFznSu2zRHgA==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz", + "integrity": "sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1008,12 +1024,12 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz", - "integrity": "sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz", + "integrity": "sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==", "dev": true, "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.5", + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1024,9 +1040,9 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.11.tgz", - "integrity": "sha512-xa7aad7q7OiT8oNZ1mU7NrISjlSkVdMbNxn9IuLZyL9AJEhs1Apba3I+u5riX1dIkdptP5EKDG5XDPByWxtehw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz", + "integrity": "sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1040,12 +1056,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.15.tgz", - "integrity": "sha512-me6VGeHsx30+xh9fbDLLPi0J1HzmeIIyenoOQHuw2D4m2SAU3NrspX5XxJLBpqn5yrLzrlw2Iy3RA//Bx27iOA==", + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.6.tgz", + "integrity": "sha512-aYH4ytZ0qSuBbpfhuofbg/e96oQ7U2w1Aw/UQmKT+1l39uEhUPoFS3fHevDc1G0OvewyDudfMKY1OulczHzWIw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -1055,13 +1072,13 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz", - "integrity": "sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz", + "integrity": "sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1072,9 +1089,9 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.11.tgz", - "integrity": "sha512-CxT5tCqpA9/jXFlme9xIBCc5RPtdDq3JpkkhgHQqtDdiTnTI0jtZ0QzXhr5DILeYifDPp2wvY2ad+7+hLMW5Pw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz", + "integrity": "sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1088,9 +1105,9 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz", - "integrity": "sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz", + "integrity": "sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1103,9 +1120,9 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.11.tgz", - "integrity": "sha512-qQwRTP4+6xFCDV5k7gZBF3C31K34ut0tbEcTKxlX/0KXxm9GLcO14p570aWxFvVzx6QAfPgq7gaeIHXJC8LswQ==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz", + "integrity": "sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1119,9 +1136,9 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz", - "integrity": "sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz", + "integrity": "sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1134,12 +1151,12 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.0.tgz", - "integrity": "sha512-xWT5gefv2HGSm4QHtgc1sYPbseOyf+FFDo2JbpE25GWl5BqTGO9IMwTYJRoIdjsF85GE+VegHxSCUt5EvoYTAw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz", + "integrity": "sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.23.0", + "@babel/helper-module-transforms": "^7.23.3", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1150,12 +1167,12 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.0.tgz", - "integrity": "sha512-32Xzss14/UVc7k9g775yMIvkVK8xwKE0DPdP5JTapr3+Z9w4tzeOuLNY6BXDQR6BdnzIlXnCGAzsk/ICHBLVWQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz", + "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.23.0", + "@babel/helper-module-transforms": "^7.23.3", "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-simple-access": "^7.22.5" }, @@ -1167,13 +1184,13 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.0.tgz", - "integrity": "sha512-qBej6ctXZD2f+DhlOC9yO47yEYgUh5CZNz/aBoH4j/3NOlRfJXJbY7xDQCqQVf9KbrqGzIWER1f23doHGrIHFg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz", + "integrity": "sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==", "dev": true, "dependencies": { "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-module-transforms": "^7.23.0", + "@babel/helper-module-transforms": "^7.23.3", "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-validator-identifier": "^7.22.20" }, @@ -1185,12 +1202,12 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz", - "integrity": "sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz", + "integrity": "sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.3", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1217,9 +1234,9 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz", - "integrity": "sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz", + "integrity": "sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1232,9 +1249,9 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.11.tgz", - "integrity": "sha512-YZWOw4HxXrotb5xsjMJUDlLgcDXSfO9eCmdl1bgW4+/lAGdkjaEvOnQ4p5WKKdUgSzO39dgPl0pTnfxm0OAXcg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz", + "integrity": "sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1248,9 +1265,9 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.11.tgz", - "integrity": "sha512-3dzU4QGPsILdJbASKhF/V2TVP+gJya1PsueQCxIPCEcerqF21oEcrob4mzjsp2Py/1nLfF5m+xYNMDpmA8vffg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz", + "integrity": "sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1264,16 +1281,16 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.15.tgz", - "integrity": "sha512-fEB+I1+gAmfAyxZcX1+ZUwLeAuuf8VIg67CTznZE0MqVFumWkh8xWtn58I4dxdVf080wn7gzWoF8vndOViJe9Q==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz", + "integrity": "sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.22.9", + "@babel/compat-data": "^7.23.3", "@babel/helper-compilation-targets": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.22.15" + "@babel/plugin-transform-parameters": "^7.23.3" }, "engines": { "node": ">=6.9.0" @@ -1283,13 +1300,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz", - "integrity": "sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz", + "integrity": "sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.5" + "@babel/helper-replace-supers": "^7.22.20" }, "engines": { "node": ">=6.9.0" @@ -1299,9 +1316,9 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.11.tgz", - "integrity": "sha512-rli0WxesXUeCJnMYhzAglEjLWVDF6ahb45HuprcmQuLidBJFWjNnOzssk2kuc6e33FlLaiZhG/kUIzUMWdBKaQ==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz", + "integrity": "sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1315,9 +1332,9 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.0.tgz", - "integrity": "sha512-sBBGXbLJjxTzLBF5rFWaikMnOGOk/BmK6vVByIdEggZ7Vn6CvWXZyRkkLFK6WE0IF8jSliyOkUN6SScFgzCM0g==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz", + "integrity": "sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1332,9 +1349,9 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.15.tgz", - "integrity": "sha512-hjk7qKIqhyzhhUvRT683TYQOFa/4cQKwQy7ALvTpODswN40MljzNDa0YldevS6tGbxwaEKVn502JmY0dP7qEtQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz", + "integrity": "sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1347,12 +1364,12 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz", - "integrity": "sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz", + "integrity": "sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1363,13 +1380,13 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.11.tgz", - "integrity": "sha512-sSCbqZDBKHetvjSwpyWzhuHkmW5RummxJBVbYLkGkaiTOWGxml7SXt0iWa03bzxFIx7wOj3g/ILRd0RcJKBeSQ==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz", + "integrity": "sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.22.11", + "@babel/helper-create-class-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-private-property-in-object": "^7.14.5" }, @@ -1381,9 +1398,9 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz", - "integrity": "sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz", + "integrity": "sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1396,9 +1413,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz", - "integrity": "sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz", + "integrity": "sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1412,9 +1429,9 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz", - "integrity": "sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz", + "integrity": "sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1427,9 +1444,9 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz", - "integrity": "sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz", + "integrity": "sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1442,9 +1459,9 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz", - "integrity": "sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz", + "integrity": "sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5", @@ -1458,9 +1475,9 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz", - "integrity": "sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz", + "integrity": "sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1473,9 +1490,9 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz", - "integrity": "sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz", + "integrity": "sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1488,9 +1505,9 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz", - "integrity": "sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz", + "integrity": "sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1503,9 +1520,9 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz", - "integrity": "sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz", + "integrity": "sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==", "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" @@ -1518,12 +1535,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz", - "integrity": "sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz", + "integrity": "sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-create-regexp-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1534,12 +1551,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz", - "integrity": "sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz", + "integrity": "sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-create-regexp-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1550,12 +1567,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz", - "integrity": "sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz", + "integrity": "sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-create-regexp-features-plugin": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5" }, "engines": { @@ -1566,25 +1583,26 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.2.tgz", - "integrity": "sha512-BW3gsuDD+rvHL2VO2SjAUNTBe5YrjsTiDyqamPDWY723na3/yPQ65X5oQkFVJZ0o50/2d+svm1rkPoJeR1KxVQ==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.7.tgz", + "integrity": "sha512-SY27X/GtTz/L4UryMNJ6p4fH4nsgWbz84y9FE0bQeWJP6O5BhgVCt53CotQKHCOeXJel8VyhlhujhlltKms/CA==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.2", - "@babel/helper-compilation-targets": "^7.22.15", + "@babel/compat-data": "^7.23.5", + "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.15", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.15", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.15", + "@babel/helper-validator-option": "^7.23.5", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.22.5", - "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-assertions": "^7.23.3", + "@babel/plugin-syntax-import-attributes": "^7.23.3", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", @@ -1596,59 +1614,58 @@ "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.22.5", - "@babel/plugin-transform-async-generator-functions": "^7.23.2", - "@babel/plugin-transform-async-to-generator": "^7.22.5", - "@babel/plugin-transform-block-scoped-functions": "^7.22.5", - "@babel/plugin-transform-block-scoping": "^7.23.0", - "@babel/plugin-transform-class-properties": "^7.22.5", - "@babel/plugin-transform-class-static-block": "^7.22.11", - "@babel/plugin-transform-classes": "^7.22.15", - "@babel/plugin-transform-computed-properties": "^7.22.5", - "@babel/plugin-transform-destructuring": "^7.23.0", - "@babel/plugin-transform-dotall-regex": "^7.22.5", - "@babel/plugin-transform-duplicate-keys": "^7.22.5", - "@babel/plugin-transform-dynamic-import": "^7.22.11", - "@babel/plugin-transform-exponentiation-operator": "^7.22.5", - "@babel/plugin-transform-export-namespace-from": "^7.22.11", - "@babel/plugin-transform-for-of": "^7.22.15", - "@babel/plugin-transform-function-name": "^7.22.5", - "@babel/plugin-transform-json-strings": "^7.22.11", - "@babel/plugin-transform-literals": "^7.22.5", - "@babel/plugin-transform-logical-assignment-operators": "^7.22.11", - "@babel/plugin-transform-member-expression-literals": "^7.22.5", - "@babel/plugin-transform-modules-amd": "^7.23.0", - "@babel/plugin-transform-modules-commonjs": "^7.23.0", - "@babel/plugin-transform-modules-systemjs": "^7.23.0", - "@babel/plugin-transform-modules-umd": "^7.22.5", + "@babel/plugin-transform-arrow-functions": "^7.23.3", + "@babel/plugin-transform-async-generator-functions": "^7.23.7", + "@babel/plugin-transform-async-to-generator": "^7.23.3", + "@babel/plugin-transform-block-scoped-functions": "^7.23.3", + "@babel/plugin-transform-block-scoping": "^7.23.4", + "@babel/plugin-transform-class-properties": "^7.23.3", + "@babel/plugin-transform-class-static-block": "^7.23.4", + "@babel/plugin-transform-classes": "^7.23.5", + "@babel/plugin-transform-computed-properties": "^7.23.3", + "@babel/plugin-transform-destructuring": "^7.23.3", + "@babel/plugin-transform-dotall-regex": "^7.23.3", + "@babel/plugin-transform-duplicate-keys": "^7.23.3", + "@babel/plugin-transform-dynamic-import": "^7.23.4", + "@babel/plugin-transform-exponentiation-operator": "^7.23.3", + "@babel/plugin-transform-export-namespace-from": "^7.23.4", + "@babel/plugin-transform-for-of": "^7.23.6", + "@babel/plugin-transform-function-name": "^7.23.3", + "@babel/plugin-transform-json-strings": "^7.23.4", + "@babel/plugin-transform-literals": "^7.23.3", + "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", + "@babel/plugin-transform-member-expression-literals": "^7.23.3", + "@babel/plugin-transform-modules-amd": "^7.23.3", + "@babel/plugin-transform-modules-commonjs": "^7.23.3", + "@babel/plugin-transform-modules-systemjs": "^7.23.3", + "@babel/plugin-transform-modules-umd": "^7.23.3", "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.22.5", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11", - "@babel/plugin-transform-numeric-separator": "^7.22.11", - "@babel/plugin-transform-object-rest-spread": "^7.22.15", - "@babel/plugin-transform-object-super": "^7.22.5", - "@babel/plugin-transform-optional-catch-binding": "^7.22.11", - "@babel/plugin-transform-optional-chaining": "^7.23.0", - "@babel/plugin-transform-parameters": "^7.22.15", - "@babel/plugin-transform-private-methods": "^7.22.5", - "@babel/plugin-transform-private-property-in-object": "^7.22.11", - "@babel/plugin-transform-property-literals": "^7.22.5", - "@babel/plugin-transform-regenerator": "^7.22.10", - "@babel/plugin-transform-reserved-words": "^7.22.5", - "@babel/plugin-transform-shorthand-properties": "^7.22.5", - "@babel/plugin-transform-spread": "^7.22.5", - "@babel/plugin-transform-sticky-regex": "^7.22.5", - "@babel/plugin-transform-template-literals": "^7.22.5", - "@babel/plugin-transform-typeof-symbol": "^7.22.5", - "@babel/plugin-transform-unicode-escapes": "^7.22.10", - "@babel/plugin-transform-unicode-property-regex": "^7.22.5", - "@babel/plugin-transform-unicode-regex": "^7.22.5", - "@babel/plugin-transform-unicode-sets-regex": "^7.22.5", + "@babel/plugin-transform-new-target": "^7.23.3", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", + "@babel/plugin-transform-numeric-separator": "^7.23.4", + "@babel/plugin-transform-object-rest-spread": "^7.23.4", + "@babel/plugin-transform-object-super": "^7.23.3", + "@babel/plugin-transform-optional-catch-binding": "^7.23.4", + "@babel/plugin-transform-optional-chaining": "^7.23.4", + "@babel/plugin-transform-parameters": "^7.23.3", + "@babel/plugin-transform-private-methods": "^7.23.3", + "@babel/plugin-transform-private-property-in-object": "^7.23.4", + "@babel/plugin-transform-property-literals": "^7.23.3", + "@babel/plugin-transform-regenerator": "^7.23.3", + "@babel/plugin-transform-reserved-words": "^7.23.3", + "@babel/plugin-transform-shorthand-properties": "^7.23.3", + "@babel/plugin-transform-spread": "^7.23.3", + "@babel/plugin-transform-sticky-regex": "^7.23.3", + "@babel/plugin-transform-template-literals": "^7.23.3", + "@babel/plugin-transform-typeof-symbol": "^7.23.3", + "@babel/plugin-transform-unicode-escapes": "^7.23.3", + "@babel/plugin-transform-unicode-property-regex": "^7.23.3", + "@babel/plugin-transform-unicode-regex": "^7.23.3", + "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", "@babel/preset-modules": "0.1.6-no-external-plugins", - "@babel/types": "^7.23.0", - "babel-plugin-polyfill-corejs2": "^0.4.6", - "babel-plugin-polyfill-corejs3": "^0.8.5", - "babel-plugin-polyfill-regenerator": "^0.5.3", + "babel-plugin-polyfill-corejs2": "^0.4.7", + "babel-plugin-polyfill-corejs3": "^0.8.7", + "babel-plugin-polyfill-regenerator": "^0.5.4", "core-js-compat": "^3.31.0", "semver": "^6.3.1" }, @@ -1680,9 +1697,9 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz", - "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.7.tgz", + "integrity": "sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1705,20 +1722,20 @@ } }, "node_modules/@babel/traverse": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", - "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "version": "7.23.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", + "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "debug": "^4.1.0", + "@babel/parser": "^7.23.6", + "@babel/types": "^7.23.6", + "debug": "^4.3.1", "globals": "^11.1.0" }, "engines": { @@ -1726,12 +1743,12 @@ } }, "node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", + "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, @@ -1745,9 +1762,9 @@ "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==" }, "node_modules/@codemirror/autocomplete": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.10.2.tgz", - "integrity": "sha512-3dCL7b0j2GdtZzWN5j7HDpRAJ26ip07R4NGYz7QYthIYMiX8I4E4TNrYcdTayPJGeVQtd/xe7lWU4XL7THFb/w==", + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.11.1.tgz", + "integrity": "sha512-L5UInv8Ffd6BPw0P3EF7JLYAMeEbclY7+6Q11REt8vhih8RuLreKtPy/xk8wPxs4EQgYqzI7cdgpiYwWlbS/ow==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -1762,12 +1779,12 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.3.0.tgz", - "integrity": "sha512-tFfcxRIlOWiQDFhjBSWJ10MxcvbCIsRr6V64SgrcaY0MwNk32cUOcCuNlWo8VjV4qRQCgNgUAnIeo0svkk4R5Q==", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.3.3.tgz", + "integrity": "sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==", "dependencies": { "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.2.0", + "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.1.0" } @@ -1785,9 +1802,9 @@ } }, "node_modules/@codemirror/lang-html": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.6.tgz", - "integrity": "sha512-E4C8CVupBksXvgLSme/zv31x91g06eZHSph7NczVxZW+/K+3XgJGWNT//2WLzaKSBoxpAjaOi5ZnPU1SHhjh3A==", + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.7.tgz", + "integrity": "sha512-y9hWSSO41XlcL4uYwWyk0lEgTHcelWWfRuqmvcAmxfCs0HNWZdriWo/EU43S63SxEZpc1Hd50Itw7ktfQvfkUg==", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -1815,9 +1832,9 @@ } }, "node_modules/@codemirror/lang-markdown": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.2.tgz", - "integrity": "sha512-wmwM9Y5n/e4ndU51KcYDaQnb9goYdhXjU71dDW9goOc1VUTIPph6WKAPdJ6BzC0ZFy+UTsDwTXGWSP370RH69Q==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.3.tgz", + "integrity": "sha512-wCewRLWpdefWi7uVkHIDiE8+45Fe4buvMDZkihqEom5uRUQrl76Zb13emjeK3W+8pcRgRfAmwelURBbxNEKCIg==", "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", @@ -1829,12 +1846,12 @@ } }, "node_modules/@codemirror/language": { - "version": "6.9.2", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.9.2.tgz", - "integrity": "sha512-QGTQXSpAKDIzaSE96zNK1UfIUhPgkT1CLjh1N5qVzZuxgsEOhz5RqaN8QCIdyOQklGLx3MgHd9YrE3X3+Pl1ow==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.0.tgz", + "integrity": "sha512-2vaNn9aPGCRFKWcHPFksctzJ8yS5p7YoaT+jHpc0UGKzNuAIx4qy6R5wiqbP+heEEdyaABA582mNqSHzSoYdmg==", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", @@ -1852,16 +1869,16 @@ } }, "node_modules/@codemirror/state": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.3.1.tgz", - "integrity": "sha512-88e4HhMtKJyw6fKprGaN/yZfiaoGYOi2nM45YCUC6R/kex9sxFWBDGatS1vk4lMgnWmdIIB9tk8Gj1LmL8YfvA==" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.0.tgz", + "integrity": "sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==" }, "node_modules/@codemirror/view": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.22.0.tgz", - "integrity": "sha512-6zLj4YIoIpfTGKrDMTbeZRpa8ih4EymMCKmddEDcJWrCdp/N1D46B38YEz4creTb4T177AVS9EyXkLeC/HL2jA==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.23.0.tgz", + "integrity": "sha512-/51px9N4uW8NpuWkyUX+iam5+PM6io2fm+QmRnzwqBy5v/pwGg9T0kILFtYeum8hjuvENtgsGNKluOfqIICmeQ==", "dependencies": { - "@codemirror/state": "^6.1.4", + "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } @@ -1953,9 +1970,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dependencies": { "type-fest": "^0.20.2" }, @@ -2056,41 +2073,42 @@ } }, "node_modules/@lezer/common": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.1.0.tgz", - "integrity": "sha512-XPIN3cYDXsoJI/oDWoR2tD++juVrhgIago9xyKhZ7IhGlzdDM9QgC8D8saKNCz5pindGcznFr2HBSsEQSWnSjw==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.0.tgz", + "integrity": "sha512-Wmvlm4q6tRpwiy20TnB3yyLTZim38Tkc50dPY8biQRwqE+ati/wD84rm3N15hikvdT4uSg9phs9ubjvcLmkpKg==" }, "node_modules/@lezer/css": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.3.tgz", - "integrity": "sha512-SjSM4pkQnQdJDVc80LYzEaMiNy9txsFbI7HsMgeVF28NdLaAdHNtQ+kB/QqDUzRBV/75NTXjJ/R5IdC8QQGxMg==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.6.tgz", + "integrity": "sha512-/HhbnfXchRc995VdDH9TBzd1B2CO/A4uhOhELqGjd7Bymgc+tGlb0W9Vp5GA1Otq8Ef4JCXpuKmr4hH3aFny6A==", "dependencies": { + "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "node_modules/@lezer/highlight": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.1.6.tgz", - "integrity": "sha512-cmSJYa2us+r3SePpRCjN5ymCqCPv+zyXmDl0ciWtVaNiORT/MxM7ZgOMQZADD0o51qOaOg24qc/zBViOIwAjJg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", + "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", "dependencies": { "@lezer/common": "^1.0.0" } }, "node_modules/@lezer/html": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.6.tgz", - "integrity": "sha512-Kk9HJARZTc0bAnMQUqbtuhFVsB4AnteR2BFUWfZV7L/x1H0aAKz6YabrfJ2gk/BEgjh9L3hg5O4y2IDZRBdzuQ==", + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.8.tgz", + "integrity": "sha512-EXseJ3pUzWxE6XQBQdqWHZqqlGQRSuNMBcLb6mZWS2J2v+QZhOObD+3ZIKIcm59ntTzyor4LqFTb72iJc3k23Q==", "dependencies": { - "@lezer/common": "^1.0.0", + "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "node_modules/@lezer/javascript": { - "version": "1.4.9", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.9.tgz", - "integrity": "sha512-7Uv8mBBE6l44spgWEZvEMdDqGV+FIuY7kJ1o5TFm+jxIuxydO3PcKJYiINij09igd1D/9P7l2KDqpkN8c3bM6A==", + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.12.tgz", + "integrity": "sha512-kwO5MftUiyfKBcECMEDc4HYnc10JME9kTJNPVoCXqJj/Y+ASWF0rgstORi3BThlQI6SoPSshrK5TjuiLFnr29A==", "dependencies": { "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" @@ -2105,9 +2123,9 @@ } }, "node_modules/@lezer/markdown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.1.0.tgz", - "integrity": "sha512-JYOI6Lkqbl83semCANkO3CKbKc0pONwinyagBufWBm+k4yhIcqfCF8B8fpEpvJLmIy7CAfwiq7dQ/PzUZA340g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.2.0.tgz", + "integrity": "sha512-d7MwsfAukZJo1GpPrcPGa3MxaFFOqNp0gbqF+3F7pTeNDOgeJN1muXzx1XXDPt+Ac+/voCzsH7qXqnn+xReG/g==", "dependencies": { "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0" @@ -2237,9 +2255,9 @@ } }, "node_modules/@types/d3": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.2.tgz", - "integrity": "sha512-Y4g2Yb30ZJmmtqAJTqMRaqXwRawfvpdpVmyEYEcyGNhrQI/Zvkq3k7yE1tdN07aFSmNBfvmegMQ9Fe2qy9ZMhw==", + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", @@ -2274,185 +2292,185 @@ } }, "node_modules/@types/d3-array": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.0.tgz", - "integrity": "sha512-tjU8juPSfhMnu6mJZPOCVVGba4rZoE0tjHDPb81PYwA8CzbaFscGjgkUM7juUJu6iWA1cCVWNEVwxZ5HN9Jj8Q==" + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" }, "node_modules/@types/d3-axis": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.5.tgz", - "integrity": "sha512-ufDAV3SQzju+uB3Jlty7SUb/jMigjpIlvDDcSGvGmmO6OT/sNO93UE0dRzwWOZeBLzrLSA0CQM4bf3iq1std3A==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", "dependencies": { "@types/d3-selection": "*" } }, "node_modules/@types/d3-brush": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.5.tgz", - "integrity": "sha512-JROQXZNq1X6QdWstESDUv1VilwZ2hBCQnWB91yal+5yZvYwGQvYsGCjrkHGfKK/8/AcX1JnERmpQzdDDuLRUsA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", "dependencies": { "@types/d3-selection": "*" } }, "node_modules/@types/d3-chord": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.5.tgz", - "integrity": "sha512-rs26AIhJjtc+XLR4YQU8IjPTLOlDVO4PR1y+pVFYEHzKh2tE5tYz3MF4QV6iz7HboXQEaYpJQt8dH9uUkne8yA==" + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==" }, "node_modules/@types/d3-color": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.2.tgz", - "integrity": "sha512-At+Ski7dL8Bs58E8g8vPcFJc8tGcaC12Z4m07+p41+DRqnZQcAlp3NfYjLrhNYv+zEyQitU1CUxXNjqUyf+c0g==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, "node_modules/@types/d3-contour": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.5.tgz", - "integrity": "sha512-wLvjwdOQVd1NL1IcW90CCt1VtpeZ3V20p/OTXlkT8uAiprrJnq2PNNnRNe1QCez4U9aMU29Z14zpJQVLW1+Lcg==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "node_modules/@types/d3-delaunay": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.3.tgz", - "integrity": "sha512-+Lf5NPKZ4JBC9tbudVkKceQXRxU3jJs0el9aKQvinMtdnFSOG84eVXyhCNgIFuXNQO3iIcYs7sgzN359FEOZnQ==" + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" }, "node_modules/@types/d3-dispatch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.5.tgz", - "integrity": "sha512-hxvq2kc+9hydVppo21JCGfcM0tLTh1DXnG3MLN0KlxsNZJH4bsdl1iXDuWtXFpWWlBrCMwSqlnoLPDxNAZU3Bg==" + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==" }, "node_modules/@types/d3-drag": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.5.tgz", - "integrity": "sha512-arHyAGvO0NEGGPCU2jTb31TlXeSxwty1bIxr5wOFOCVqVjgriXloLWXoRp39Oa0Y/qXxcAVMIonAWLrtLxUZAQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", "dependencies": { "@types/d3-selection": "*" } }, "node_modules/@types/d3-dsv": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.5.tgz", - "integrity": "sha512-73WZR3QFOaSRVz9iOrebTbTnbo7xjcgS/i0Cq5zy0jMXPO3v/JbkTD3Zqii1eYE6v4EJ78g5VP407rm+p8fdlA==" + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==" }, "node_modules/@types/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-VZofjpEt8HWv3nxUAosj5o/+4JflnJ7Bbv07k17VO3T2WRuzGdZeookfaF60iVh5RdhVG49LE5w6LIshVUC6rg==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" }, "node_modules/@types/d3-fetch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.5.tgz", - "integrity": "sha512-Rc8pb6H0RRLpAV2hEXduykUgcDUOhjSLTLmCIeo6ejzgs4SaITh/EteMb3p5Env3Hqjsqw0fCksyqopHHzMkMg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", "dependencies": { "@types/d3-dsv": "*" } }, "node_modules/@types/d3-force": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.7.tgz", - "integrity": "sha512-rsok4CEvPLyVWRPsFiBhanJc3up03H/EARVz4d8soPh8drv82YMuAckYy4yv8g4/81JwCng5U5/o9aj9d0T6bQ==" + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.9.tgz", + "integrity": "sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA==" }, "node_modules/@types/d3-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.3.tgz", - "integrity": "sha512-kxuLXSAEJykTeL/EI3tUiEfGqru7PRdqEy099YBnqFl+fF167UVSB4+wntlZv86ZdoYf0DHjsRHnTIm8kcH7qw==" + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==" }, "node_modules/@types/d3-geo": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.0.6.tgz", - "integrity": "sha512-wblAES3b+C3hvp4VakwECEKtHquT/xc6K4HOna95LM1j1fd7s7WmU4V+JMQZfKhNCMkV2vWD+ZUgY2Uj6gqfuA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", "dependencies": { "@types/geojson": "*" } }, "node_modules/@types/d3-hierarchy": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.5.tgz", - "integrity": "sha512-DEcBUj1IL3WyPLDlh4m2nsNXnMLITXM5Vwcu4G85yJHtf2cVGPBjgky3L11WBnT+ayHKf06Tchk5mY1eGmd4WQ==" + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.6.tgz", + "integrity": "sha512-qlmD/8aMk5xGorUvTUWHCiumvgaUXYldYjNVOWtYoTYY/L+WwIEAmJxUmTgr9LoGNG0PPAOmqMDJVDPc7DOpPw==" }, "node_modules/@types/d3-interpolate": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.3.tgz", - "integrity": "sha512-6OZ2EIB4lLj+8cUY7I/Cgn9Q+hLdA4DjJHYOQDiHL0SzqS1K9DL5xIOVBSIHgF+tiuO9MU1D36qvdIvRDRPh+Q==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "dependencies": { "@types/d3-color": "*" } }, "node_modules/@types/d3-path": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.1.tgz", - "integrity": "sha512-blRhp7ki7pVznM8k6lk5iUU9paDbVRVq+/xpf0RRgSJn5gr6SE7RcFtxooYGMBOc1RZiGyqRpVdu5AD0z0ooMA==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.2.tgz", + "integrity": "sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==" }, "node_modules/@types/d3-polygon": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.1.tgz", - "integrity": "sha512-nrcWPk7B9qs6xnpq60Cls44zm9eDmFAv65qi/N/emh/oftnG6uYz49aIS0mdFaGeJxVN8H3pHneMuZMV8EwFdw==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==" }, "node_modules/@types/d3-quadtree": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.4.tgz", - "integrity": "sha512-B725MopFDIOQ6njFbeOxIEf42HVO2Xv+FmcxQISdOKErvLbFqWz3Riu+OWujUYoogreqqyHBHcGGL/JzzXQYsw==" + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==" }, "node_modules/@types/d3-random": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.2.tgz", - "integrity": "sha512-8QhsqkKs6mymAZMrg3ZFXPxKA34rdgp3ZrtB8o6mhFsKAd1gOvR1gocWnca+kmXypQdwgnzKm9gZE2Uw8NjjKw==" + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==" }, "node_modules/@types/d3-scale": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.6.tgz", - "integrity": "sha512-lo3oMLSiqsQUovv8j15X4BNEDOsnHuGjeVg7GRbAuB2PUa1prK5BNSOu6xixgNf3nqxPl4I1BqJWrPvFGlQoGQ==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", "dependencies": { "@types/d3-time": "*" } }, "node_modules/@types/d3-scale-chromatic": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.1.tgz", - "integrity": "sha512-Ob7OrwiTeQXY/WBBbRHGZBOn6rH1h7y3jjpTSKYqDEeqFjktql6k2XSgNwLrLDmAsXhEn8P9NHDY4VTuo0ZY1w==" + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==" }, "node_modules/@types/d3-selection": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.8.tgz", - "integrity": "sha512-pxCZUfQyedq/DIlPXIR5wE1mIH37omOdx1yxRudL3KZ4AC+156jMjOv1z5RVlGq62f8WX2kyO0hTVgEx627QFg==" + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.10.tgz", + "integrity": "sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==" }, "node_modules/@types/d3-shape": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.4.tgz", - "integrity": "sha512-M2/xsWPsjaZc5ifMKp1EBp0gqJG0eO/zlldJNOC85Y/5DGsBQ49gDkRJ2h5GY7ZVD6KUumvZWsylSbvTaJTqKg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", "dependencies": { "@types/d3-path": "*" } }, "node_modules/@types/d3-time": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.2.tgz", - "integrity": "sha512-kbdRXTmUgNfw5OTE3KZnFQn6XdIc4QGroN5UixgdrXATmYsdlPQS6pEut9tVlIojtzuFD4txs/L+Rq41AHtLpg==" + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" }, "node_modules/@types/d3-time-format": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.2.tgz", - "integrity": "sha512-wr08C1Gh77qaN8JIkrn5Rz/bdt5M9bdEqFmEOcYhUSq2t2sHvLTBfb4XAtGB3D4hm0ubj50NXWWXoXyp5tPXDg==" + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==" }, "node_modules/@types/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-GGTvzKccVEhxmRfJEB6zhY9ieT4UhGVUIQaBzFpUO9OXy2ycAlnPCSJLzmGGgqt3KVjqN3QCQB4g1rsZnHsWhg==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, "node_modules/@types/d3-transition": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.6.tgz", - "integrity": "sha512-K0To23B5UxNwFtKORnS5JoNYvw/DnknU5MzhHIS9czJ/lTqFFDeU6w9lArOdoTl0cZFNdNrMJSFCbRCEHccH2w==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.8.tgz", + "integrity": "sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==", "dependencies": { "@types/d3-selection": "*" } }, "node_modules/@types/d3-zoom": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.6.tgz", - "integrity": "sha512-dGZQaXEu7aNcCL71LPpjB58IjoQNM9oDPfQuMUJ7N/fbkcIWGX2PnmUWO1jPJ+RLbZBpRUggJUX8twKRvo2hKQ==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" @@ -2468,23 +2486,23 @@ } }, "node_modules/@types/eslint-scope": { - "version": "3.7.6", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.6.tgz", - "integrity": "sha512-zfM4ipmxVKWdxtDaJ3MP3pBurDXOCoyjvlpE3u6Qzrmw4BPbfm4/ambIeTk/r/J0iq/+2/xp0Fmt+gFvXJY2PQ==", + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "node_modules/@types/estree": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.4.tgz", - "integrity": "sha512-2JwWnHK9H+wUZNorf2Zr6ves96WHoWDJIftkcxPKsS7Djta6Zu519LarhRNljPXkpsZR2ZMwNCPeW7omW07BJw==" + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, "node_modules/@types/geojson": { - "version": "7946.0.12", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.12.tgz", - "integrity": "sha512-uK2z1ZHJyC0nQRbuovXFt4mzXDwf27vQeUWNhfKGwRcWW429GOhP8HxUHlM6TLH4bzmlv/HlEjpvJh3JfmGsAA==" + "version": "7946.0.13", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz", + "integrity": "sha512-bmrNrgKMOhM3WsafmbGmC+6dsF2Z308vLFsQ3a/bT8X8Sv5clVYpPars/UPq+sAaJP+5OoLAYgwbkS5QEJdLUQ==" }, "node_modules/@types/glob": { "version": "7.2.0", @@ -2497,9 +2515,9 @@ } }, "node_modules/@types/json-schema": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", - "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==" + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/json5": { "version": "0.0.29", @@ -2514,9 +2532,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.8.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz", - "integrity": "sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==", + "version": "20.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.7.tgz", + "integrity": "sha512-fRbIKb8C/Y2lXxB5eVMj4IU7xpdox0Lh8bUPEdtLysaylsml1hOOx1+STloRs/B9nf7C6kPRmmg/V7aQW7usNg==", "dependencies": { "undici-types": "~5.26.4" } @@ -2537,42 +2555,42 @@ "dev": true }, "node_modules/@types/sizzle": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.5.tgz", - "integrity": "sha512-tAe4Q+OLFOA/AMD+0lq8ovp8t3ysxAOeaScnfNdZpUxaGl51ZMDEITxkvFl1STudQ58mz6gzVGl9VhMKhwRnZQ==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", + "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, "node_modules/@types/source-list-map": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.4.tgz", - "integrity": "sha512-Kdfm7Sk5VX8dFW7Vbp18+fmAatBewzBILa1raHYxrGEFXT0jNl9x3LWfuW7bTbjEKFNey9Dfkj/UzT6z/NvRlg==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.6.tgz", + "integrity": "sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g==", "dev": true }, "node_modules/@types/tapable": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.10.tgz", - "integrity": "sha512-q8F20SdXG5fdVJQ5yxsVlH+f+oekP42QeHv4s5KlrxTMT0eopXn7ol1rhxMcksf8ph7XNv811iVDE2hOpUvEPg==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.12.tgz", + "integrity": "sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q==", "dev": true }, "node_modules/@types/trusted-types": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.5.tgz", - "integrity": "sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "dev": true }, "node_modules/@types/uglify-js": { - "version": "3.17.3", - "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.3.tgz", - "integrity": "sha512-ToldSfJ6wxO21cakcz63oFD1GjqQbKzhZCD57eH7zWuYT5UEZvfUoqvrjX5d+jB9g4a/sFO0n6QSVzzn5sMsjg==", + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-Hm/T0kV3ywpJyMGNbsItdivRhYNCQQf1IIsYsXnoVPES4t+FMLyDe0/K+Ea7ahWtMtSNb22ZdY7MIyoD9rqARg==", "dev": true, "dependencies": { "source-map": "^0.6.1" } }, "node_modules/@types/webpack": { - "version": "4.41.35", - "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.35.tgz", - "integrity": "sha512-XRC6HLGHtNfN8/xWeu1YUQV1GSE+28q8lSqvcJ+0xt/zW9Wmn4j9pCSvaXPyRlCKrl5OuqECQNEJUy2vo8oWqg==", + "version": "4.41.38", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.38.tgz", + "integrity": "sha512-oOW7E931XJU1mVfCnxCVgv8GLFL768pDO5u2Gzk82i8yTIgX6i7cntyZOkZYb/JtYM8252SN9bQp9tgkVDSsRw==", "dev": true, "dependencies": { "@types/node": "*", @@ -2584,9 +2602,9 @@ } }, "node_modules/@types/webpack-sources": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.2.tgz", - "integrity": "sha512-acCzhuVe+UJy8abiSFQWXELhhNMZjQjQKpLNEi1pKGgKXZj0ul614ATcx4kkhunPost6Xw+aCq8y8cn1/WwAiA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-4nZOdMwSPHZ4pTEZzSp0AsTM4K7Qmu40UKW4tJDiOVs20UzYF9l+qUe4s0ftfN0pin06n+5cWWDJXH+sbhAiDw==", "dev": true, "dependencies": { "@types/node": "*", @@ -2604,9 +2622,9 @@ } }, "node_modules/@types/yauzl": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.2.tgz", - "integrity": "sha512-Km7XAtUIduROw7QPgvcft0lIupeG8a8rdKL8RiSyKvlE7dYY31fEn41HVuQsRFDuROA8tA4K2UVL+WdfFmErBA==", + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", "dev": true, "optional": true, "dependencies": { @@ -2614,13 +2632,16 @@ } }, "node_modules/@vue/compiler-sfc": { - "version": "2.7.15", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.15.tgz", - "integrity": "sha512-FCvIEevPmgCgqFBH7wD+3B97y7u7oj/Wr69zADBf403Tui377bThTjBvekaZvlRr4IwUAu3M6hYZeULZFJbdYg==", + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz", + "integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==", "dependencies": { - "@babel/parser": "^7.18.4", + "@babel/parser": "^7.23.5", "postcss": "^8.4.14", "source-map": "^0.6.1" + }, + "optionalDependencies": { + "prettier": "^1.18.2 || ^2.0.0" } }, "node_modules/@webassemblyjs/ast": { @@ -3303,13 +3324,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.6.tgz", - "integrity": "sha512-jhHiWVZIlnPbEUKSSNb9YoWcQGdlTLq7z1GHL4AjFxaoOUMuuEVJ+Y4pAaQUGOGk93YsVCKPbqbfw3m0SM6H8Q==", + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.7.tgz", + "integrity": "sha512-LidDk/tEGDfuHW2DWh/Hgo4rmnw3cduK6ZkOI1NPFceSK3n/yAGeOsNT7FLnSGHkXj3RHGSEVkN3FsCTY6w2CQ==", "dev": true, "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.4.3", + "@babel/helper-define-polyfill-provider": "^0.4.4", "semver": "^6.3.1" }, "peerDependencies": { @@ -3317,12 +3338,12 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.6.tgz", - "integrity": "sha512-leDIc4l4tUgU7str5BWLS2h8q2N4Nf6lGZP6UrNDxdtfF2g69eJ5L0H7S8A5Ln/arfFAfHor5InAdZuIOwZdgQ==", + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz", + "integrity": "sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.3", + "@babel/helper-define-polyfill-provider": "^0.4.4", "core-js-compat": "^3.33.1" }, "peerDependencies": { @@ -3330,12 +3351,12 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.3.tgz", - "integrity": "sha512-8sHeDOmXC8csczMrYEOf0UTNa4yE2SxV5JGeT/LP1n0OYVDUUFPxG9vdk2AlDlIit4t+Kf0xCtpgXPBwnn/9pw==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.4.tgz", + "integrity": "sha512-S/x2iOCvDaCASLYsOOgWOq4bCfKYVqvO/uxjkaYyZ3rVsVE3CeAI/c84NpyuBBymEgNvHgjEot3a9/Z/kXvqsg==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.3" + "@babel/helper-define-polyfill-provider": "^0.4.4" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3426,9 +3447,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", - "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", + "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", "funding": [ { "type": "opencollective", @@ -3444,9 +3465,9 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001541", - "electron-to-chromium": "^1.4.535", - "node-releases": "^2.0.13", + "caniuse-lite": "^1.0.30001565", + "electron-to-chromium": "^1.4.601", + "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, "bin": { @@ -3538,9 +3559,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001561", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", - "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", + "version": "1.0.30001574", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz", + "integrity": "sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg==", "funding": [ { "type": "opencollective", @@ -3893,12 +3914,12 @@ } }, "node_modules/core-js-compat": { - "version": "3.33.2", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.33.2.tgz", - "integrity": "sha512-axfo+wxFVxnqf8RvxTzoAlzW4gRoacrHeoFlc9n0x50+7BEyZL/Rt3hicaED1/CEd7I6tPCPVUYcJwCMO5XUYw==", + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.0.tgz", + "integrity": "sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==", "dev": true, "dependencies": { - "browserslist": "^4.22.1" + "browserslist": "^4.22.2" }, "funding": { "type": "opencollective", @@ -3946,14 +3967,14 @@ } }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/cypress": { - "version": "13.4.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.4.0.tgz", - "integrity": "sha512-KeWNC9xSHG/ewZURVbaQsBQg2mOKw4XhjJZFKjWbEjgZCdxpPXLpJnfq5Jns1Gvnjp6AlnIfpZfWFlDgVKXdWQ==", + "version": "13.6.2", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.2.tgz", + "integrity": "sha512-TW3bGdPU4BrfvMQYv1z3oMqj71YI4AlgJgnrycicmPZAXtvywVFZW9DAToshO65D97rCWfG/kqMFsYB6Kp91gQ==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -4009,9 +4030,9 @@ } }, "node_modules/cypress/node_modules/@types/node": { - "version": "18.18.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.8.tgz", - "integrity": "sha512-OLGBaaK5V3VRBS1bAkMVP2/W9B+H8meUfl866OrMNQqt7wDgdpWPp5o6gmIc9pB+lIQHSq4ZL8ypeH1vPxcPaQ==", + "version": "18.19.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.5.tgz", + "integrity": "sha512-22MG6T02Hos2JWfa1o5jsIByn+bc5iOt1IS4xyg6OG68Bu+wMonVZzdrgCw693++rpLE9RUT/Bx15BeDzO0j+g==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -4136,9 +4157,9 @@ "dev": true }, "node_modules/cytoscape": { - "version": "3.27.0", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.27.0.tgz", - "integrity": "sha512-pPZJilfX9BxESwujODz5pydeGi+FBrXq1rcaB1mfhFXXFJ9GjE6CNndAk+8jPzoXGD+16LtSS4xlYEIUiW4Abg==", + "version": "3.28.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz", + "integrity": "sha512-xyItz4O/4zp9/239wCcH8ZcFuuZooEeF8KHRmzjDfGdXsj3OG9MFSMA0pJE0uX3uCN/ygof6hHf4L7lst+JaDg==", "dependencies": { "heap": "^0.2.6", "lodash": "^4.17.21" @@ -4827,9 +4848,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.576", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.576.tgz", - "integrity": "sha512-yXsZyXJfAqzWk1WKryr0Wl0MN2D47xodPvEEwlVePBnhU5E7raevLQR+E6b9JAD3GfL/7MbAL9ZtWQQPcLx7wA==" + "version": "1.4.623", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.623.tgz", + "integrity": "sha512-lKoz10iCYlP1WtRYdh5MvocQPWVRoI7ysp6qf18bmeBgR8abE6+I2CsfyNKztRDZvhdWc+krKT6wS7Neg8sw3A==" }, "node_modules/elkjs": { "version": "0.8.2", @@ -4995,9 +5016,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", - "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==" }, "node_modules/es-set-tostringtag": { "version": "2.0.2", @@ -5220,9 +5241,9 @@ } }, "node_modules/eslint-plugin-cypress/node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dependencies": { "type-fest": "^0.20.2" }, @@ -5245,9 +5266,9 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", - "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, "dependencies": { "array-includes": "^3.1.7", @@ -5266,7 +5287,7 @@ "object.groupby": "^1.0.1", "object.values": "^1.1.7", "semver": "^6.3.1", - "tsconfig-paths": "^3.14.2" + "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" @@ -5589,9 +5610,9 @@ } }, "node_modules/eslint/node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dependencies": { "type-fest": "^0.20.2" }, @@ -5842,9 +5863,9 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -5892,9 +5913,9 @@ } }, "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", + "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==", "dependencies": { "reusify": "^1.0.4" } @@ -6053,16 +6074,16 @@ } }, "node_modules/flat-cache": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", - "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" }, "engines": { - "node": ">=12.0.0" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/flat-cache/node_modules/rimraf": { @@ -6408,9 +6429,9 @@ } }, "node_modules/globby/node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", + "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", "engines": { "node": ">= 4" } @@ -6537,9 +6558,9 @@ "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==" }, "node_modules/htmx.org": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.7.tgz", - "integrity": "sha512-bHONSJ3WBtMG5Ex6xmC+t///rCKINrrPNeZ0Nq9cylVbzZ9ZGjWn1z60hripbqZeIM3WSpEATgm+MglDIK5WVQ==" + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.10.tgz", + "integrity": "sha512-UgchasltTCrTuU2DQLom3ohHrBvwr7OqpwyAVJ9VxtNBng4XKkVsqrv0Qr3srqvM9ZNI3f1MmvVQQqK7KW/bTA==" }, "node_modules/http-signature": { "version": "1.3.6", @@ -7906,9 +7927,9 @@ } }, "node_modules/lint-staged/node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", + "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", "dev": true, "dependencies": { "path-key": "^4.0.0" @@ -8435,14 +8456,14 @@ } }, "node_modules/markmap-common": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/markmap-common/-/markmap-common-0.15.3.tgz", - "integrity": "sha512-a40FfdzFEKoyIhd5KDsV6FfkM55WWi2spRq/cUCsOZd8e4PAHMc9auCEjdxTWRiS2EGzET9sPpvyAmPSTva+/w==", + "version": "0.15.6", + "resolved": "https://registry.npmjs.org/markmap-common/-/markmap-common-0.15.6.tgz", + "integrity": "sha512-uBJkdHvkppTiaw+IXau6aVQgN6F/o2BT6l6QghavQLrd6r7H5Ce7/EcMAg+T4RwBKqzJoraLARkIWARXuqmOgw==", "peer": true, "dependencies": { "@babel/runtime": "^7.22.6", "@gera2ld/jsx-dom": "^2.2.2", - "npm2url": "^0.2.1" + "npm2url": "^0.2.4" } }, "node_modules/markmap-lib": { @@ -8504,9 +8525,9 @@ } }, "node_modules/markmap-view": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/markmap-view/-/markmap-view-0.15.4.tgz", - "integrity": "sha512-6PJnoPZHiQIb0YE+fg0Ht1ptEXgf9QOOTBEiC2DrzjAbZ3rrRa2g+0VhpZ+9vSzDsUa+m/IGz4xkiNepuPfs5w==", + "version": "0.15.8", + "resolved": "https://registry.npmjs.org/markmap-view/-/markmap-view-0.15.8.tgz", + "integrity": "sha512-/QrVf2cxgsMUEO256SQd9w1VykT4XTF6cJwUHiUO/dF4UjyIkTQ2TMzhoVGjj2XzppQtS2mjXcjPq2ePX16RUA==", "dependencies": { "@babel/runtime": "^7.22.6", "@gera2ld/jsx-dom": "^2.2.2", @@ -8785,9 +8806,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", - "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==" + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, "node_modules/non-layered-tidy-tree-layout": { "version": "2.0.2", @@ -8815,9 +8836,9 @@ } }, "node_modules/npm2url": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/npm2url/-/npm2url-0.2.1.tgz", - "integrity": "sha512-Ls7mMyud1Kk0EisqsTt2TPtM7gLRvgmvDxOg3FPI5zjfhQ+ZFNBXX2K9VT7vo+HqUsz/uCiIxkcO0SvIuneVug==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/npm2url/-/npm2url-0.2.4.tgz", + "integrity": "sha512-arzGp/hQz0Ey+ZGhF64XVH7Xqwd+1Q/po5uGiBbzph8ebX6T0uvt3N7c1nBHQNsQVykQgHhqoRTX7JFcHecGuw==", "peer": true }, "node_modules/object-assign": { @@ -8847,13 +8868,13 @@ } }, "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", "has-symbols": "^1.0.3", "object-keys": "^1.1.1" }, @@ -9198,9 +9219,9 @@ } }, "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.4.33", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", + "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", "funding": [ { "type": "opencollective", @@ -9216,7 +9237,7 @@ } ], "dependencies": { - "nanoid": "^3.3.6", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -9236,7 +9257,7 @@ "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, + "devOptional": true, "bin": { "prettier": "bin-prettier.js" }, @@ -9510,9 +9531,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regenerator-transform": { "version": "0.15.2", @@ -9899,9 +9920,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz", - "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==", + "version": "1.69.7", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.7.tgz", + "integrity": "sha512-rzj2soDeZ8wtE2egyLXgOOHQvaC2iosZrkF6v3EUG+tBwEvhqUCzm0VP3k9gHF9LXbSrRhT5SksoI56Iw8NPnQ==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -9916,9 +9937,9 @@ } }, "node_modules/sass-loader": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.2.tgz", - "integrity": "sha512-CQbKl57kdEv+KDLquhC+gE3pXt74LEAzm+tzywcA0/aHZuub8wTErbjAoNI57rPUWRYRNC5WUnNl8eGJNbDdwg==", + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.3.tgz", + "integrity": "sha512-mt5YN2F1MOZr3d/wBRcZxeFgwgkH44wVc2zohO2YF6JiOMkiXe4BYRZpSu2sO1g71mo/j16txzUhsKZlqjVGzA==", "dev": true, "dependencies": { "neo-async": "^2.6.2" @@ -10376,9 +10397,9 @@ "integrity": "sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==" }, "node_modules/stylis": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.0.tgz", - "integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==" + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.1.tgz", + "integrity": "sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==" }, "node_modules/supports-color": { "version": "5.5.0", @@ -10532,9 +10553,9 @@ } }, "node_modules/terser": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", - "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz", + "integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -10549,15 +10570,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", - "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", + "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", "schema-utils": "^3.1.1", "serialize-javascript": "^6.0.1", - "terser": "^5.16.8" + "terser": "^5.26.0" }, "engines": { "node": ">= 10.13.0" @@ -10599,9 +10620,9 @@ } }, "node_modules/terser/node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "bin": { "acorn": "bin/acorn" }, @@ -10620,10 +10641,13 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" }, "node_modules/throttleit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz", - "integrity": "sha512-rkTVqu6IjfQ/6+uNuuc3sZek4CEYxTJom3IktzgdSxcZqdARuebbA/f4QmAxMQIxqq9ZLEUkSYqvuk1I6VKq4g==", - "dev": true + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", + "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/through": { "version": "2.3.8", @@ -10764,9 +10788,9 @@ } }, "node_modules/tsconfig-paths": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", - "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "dependencies": { "@types/json5": "^0.0.29", @@ -11070,9 +11094,9 @@ "integrity": "sha512-nah+xbVInVJaO6+C5PEUqaougmv8BN8aa7ZCtmVQcX6eWIZGRukckFtseXSI7KD7/nXtwkJe624y42T0r+L+AQ==" }, "node_modules/vega": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/vega/-/vega-5.25.0.tgz", - "integrity": "sha512-lr+uj0mhYlSN3JOKbMNp1RzZBenWp9DxJ7kR3lha58AFNCzzds7pmFa7yXPbtbaGhB7Buh/t6n+Bzk3Y0VnF5g==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/vega/-/vega-5.27.0.tgz", + "integrity": "sha512-iYMQZYb2nlJBLCsUZ88pvun2sTcFcLE7GKJWisndLo+KYNMQIRePQ7X2FRuy8yvRRNxfO8XhjImh4OwxZvyYVA==", "dependencies": { "vega-crossfilter": "~4.1.1", "vega-dataflow": "~5.7.5", @@ -11081,25 +11105,25 @@ "vega-expression": "~5.1.0", "vega-force": "~4.2.0", "vega-format": "~1.1.1", - "vega-functions": "~5.13.2", + "vega-functions": "~5.14.0", "vega-geo": "~4.4.1", "vega-hierarchy": "~4.1.1", "vega-label": "~1.2.1", "vega-loader": "~4.5.1", - "vega-parser": "~6.2.0", + "vega-parser": "~6.2.1", "vega-projection": "~1.6.0", "vega-regression": "~1.2.0", "vega-runtime": "~6.1.4", - "vega-scale": "~7.3.0", - "vega-scenegraph": "~4.10.2", + "vega-scale": "~7.3.1", + "vega-scenegraph": "~4.11.2", "vega-statistics": "~1.9.0", "vega-time": "~2.1.1", - "vega-transforms": "~4.10.2", - "vega-typings": "~0.24.0", + "vega-transforms": "~4.11.1", + "vega-typings": "~1.1.0", "vega-util": "~1.17.2", - "vega-view": "~5.11.1", + "vega-view": "~5.12.0", "vega-view-transforms": "~4.5.9", - "vega-voronoi": "~4.2.1", + "vega-voronoi": "~4.2.2", "vega-wordcloud": "~4.1.4" } }, @@ -11129,9 +11153,9 @@ } }, "node_modules/vega-embed": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/vega-embed/-/vega-embed-6.23.0.tgz", - "integrity": "sha512-8Iava57LdROsatqsOhMLErHYaBpZBB7yZJlSVU3/xOK3l8Ft5WFnj5fm3OOAVML97/0yULE7LRVjXW5hV3fSpg==", + "version": "6.24.0", + "resolved": "https://registry.npmjs.org/vega-embed/-/vega-embed-6.24.0.tgz", + "integrity": "sha512-ANCksO3lXhdLzQn7Mfistm1dsRwQhxTYICVpru7TMc+Ywe7C4vOLWuaVv9Qvem2IgQyuVCal0EoTMKvIVYykFg==", "bundleDependencies": [ "yallist" ], @@ -11139,11 +11163,11 @@ "fast-json-patch": "^3.1.1", "json-stringify-pretty-compact": "^3.0.0", "semver": "^7.5.4", - "tslib": "^2.6.1", + "tslib": "^2.6.2", "vega-interpreter": "^1.0.5", "vega-schema-url-parser": "^2.2.0", "vega-themes": "^2.14.0", - "vega-tooltip": "^0.33.0", + "vega-tooltip": "^0.34.0", "yallist": "*" }, "peerDependencies": { @@ -11231,9 +11255,9 @@ } }, "node_modules/vega-functions": { - "version": "5.13.2", - "resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-5.13.2.tgz", - "integrity": "sha512-YE1Xl3Qi28kw3vdXVYgKFMo20ttd3+SdKth1jUNtBDGGdrOpvPxxFhZkVqX+7FhJ5/1UkDoAYs/cZY0nRKiYgA==", + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-5.14.0.tgz", + "integrity": "sha512-Q0rocHmJDfQ0tS91kdN8WcEosq1e3HPK1Yf5z36SPYPmTzKw3uxUGE52tLxC832acAYqPmi8R41wAoI/yFQTPg==", "dependencies": { "d3-array": "^3.2.2", "d3-color": "^3.1.0", @@ -11242,7 +11266,7 @@ "vega-expression": "^5.1.0", "vega-scale": "^7.3.0", "vega-scenegraph": "^4.10.2", - "vega-selections": "^5.4.1", + "vega-selections": "^5.4.2", "vega-statistics": "^1.8.1", "vega-time": "^2.1.1", "vega-util": "^1.17.1" @@ -11298,9 +11322,9 @@ } }, "node_modules/vega-lite": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-5.16.1.tgz", - "integrity": "sha512-3iXmzdAVZCGHrvdh6hIM8OY55auXA1EIDzFLaYdq27e99Dr+WXTEa00ilqQUPdrpS0sE1ZqK4Ikhgg5x8SOtLw==", + "version": "5.16.3", + "resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-5.16.3.tgz", + "integrity": "sha512-F3HO/BqlyyB1D0tf/+qy1JOmq7bHtG/nvsXcgNVUFjgVgvVKL4sMnxVnYzSsIg10x/6RFxLfwWJSd0cA8MuuUA==", "dependencies": { "json-stringify-pretty-compact": "~3.0.0", "tslib": "~2.6.2", @@ -11335,15 +11359,15 @@ } }, "node_modules/vega-parser": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vega-parser/-/vega-parser-6.2.0.tgz", - "integrity": "sha512-as+QnX8Qxe9q51L1C2sVBd+YYYctP848+zEvkBT2jlI2g30aZ6Uv7sKsq7QTL6DUbhXQKR0XQtzlanckSFdaOQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/vega-parser/-/vega-parser-6.2.1.tgz", + "integrity": "sha512-F79bQXt6fMkACR+TfFl7ueehKO26yCR/3iRZxhU7/pgHerx/d8K8pf2onMguu3NAN4eitT+PPuTgkDZtcqo9Qg==", "dependencies": { "vega-dataflow": "^5.7.5", "vega-event-selector": "^3.0.1", - "vega-functions": "^5.13.1", - "vega-scale": "^7.3.0", - "vega-util": "^1.17.1" + "vega-functions": "^5.14.0", + "vega-scale": "^7.3.1", + "vega-util": "^1.17.2" } }, "node_modules/vega-projection": { @@ -11377,9 +11401,9 @@ } }, "node_modules/vega-scale": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vega-scale/-/vega-scale-7.3.0.tgz", - "integrity": "sha512-pMOAI2h+e1z7lsqKG+gMfR6NKN2sTcyjZbdJwntooW0uFHwjLGjMSY7kSd3nSEquF0HQ8qF7zR6gs1eRwlGimw==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vega-scale/-/vega-scale-7.3.1.tgz", + "integrity": "sha512-tyTlaaCpHN2Ik/PPKl/j9ThadBDjPtypqW1D7IsUSkzfoZ7RPlI2jwAaoj2C/YW5jFRbEOx3njmjogp48I5CvA==", "dependencies": { "d3-array": "^3.2.2", "d3-interpolate": "^3.0.1", @@ -11389,9 +11413,9 @@ } }, "node_modules/vega-scenegraph": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/vega-scenegraph/-/vega-scenegraph-4.10.2.tgz", - "integrity": "sha512-R8m6voDZO5+etwNMcXf45afVM3XAtokMqxuDyddRl9l1YqSJfS+3u8hpolJ50c2q6ZN20BQiJwKT1o0bB7vKkA==", + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/vega-scenegraph/-/vega-scenegraph-4.11.2.tgz", + "integrity": "sha512-PXSvv/L7Ek+9mwOTPLpzgkXdfGCR+AcWV5aquPGrqCWoiIF49VJkKFNT1HWxj3RZJX0XKo2r7SuXvRBb9EJ1aA==", "dependencies": { "d3-path": "^3.1.0", "d3-shape": "^3.2.0", @@ -11407,26 +11431,15 @@ "integrity": "sha512-yAtdBnfYOhECv9YC70H2gEiqfIbVkq09aaE4y/9V/ovEFmH9gPKaEgzIZqgT7PSPQjKhsNkb6jk6XvSoboxOBw==" }, "node_modules/vega-selections": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-5.4.1.tgz", - "integrity": "sha512-EtYc4DvA+wXqBg9tq+kDomSoVUPCmQfS7hUxy2qskXEed79YTimt3Hcl1e1fW226I4AVDBEqTTKebmKMzbSgAA==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-5.4.2.tgz", + "integrity": "sha512-99FUhYmg0jOJr2/K4TcEURmJRkuibrCDc8KBUX7qcQEITzrZ5R6a4QE+sarCvbb3hi8aA9GV2oyST6MQeA9mgQ==", "dependencies": { - "d3-array": "3.2.2", + "d3-array": "3.2.4", "vega-expression": "^5.0.1", "vega-util": "^1.17.1" } }, - "node_modules/vega-selections/node_modules/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-yEEyEAbDrF8C6Ob2myOBLjwBLck1Z89jMGFee0oPsn95GqjerpaOA4ch+vc2l0FNFFwMD5N7OCSEN5eAlsUbgQ==", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/vega-statistics": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/vega-statistics/-/vega-statistics-1.9.0.tgz", @@ -11455,17 +11468,17 @@ } }, "node_modules/vega-tooltip": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/vega-tooltip/-/vega-tooltip-0.33.0.tgz", - "integrity": "sha512-jMcvH2lP20UfyvO2KAEdloiwRyasikaiLuNFhzwrrzf2RamGTxP4G7B2OZ2QENfrGUH05Z9ei5tn/eErdzOaZQ==", + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/vega-tooltip/-/vega-tooltip-0.34.0.tgz", + "integrity": "sha512-TtxwkcLZ5aWQTvKGlfWDou8tISGuxmqAW1AgGZjrDpf75qsXvgtbPdRAAls2LZMqDxpr5T1kMEZs9XbSpiI8yw==", "dependencies": { "vega-util": "^1.17.2" } }, "node_modules/vega-transforms": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/vega-transforms/-/vega-transforms-4.10.2.tgz", - "integrity": "sha512-sJELfEuYQ238PRG+GOqQch8D69RYnJevYSGLsRGQD2LxNz3j+GlUX6Pid+gUEH5HJy22Q5L0vsTl2ZNhIr4teQ==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/vega-transforms/-/vega-transforms-4.11.1.tgz", + "integrity": "sha512-DDbqEQnvy9/qEvv0bAKPqAuzgaNb7Lh2xKJFom2Yzx4tZHCl8dnKxC1lH9JnJlAMdtZuiNLPARUkf3pCNQ/olw==", "dependencies": { "d3-array": "^3.2.2", "vega-dataflow": "^5.7.5", @@ -11475,14 +11488,14 @@ } }, "node_modules/vega-typings": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/vega-typings/-/vega-typings-0.24.2.tgz", - "integrity": "sha512-fW02GElYoqweCCaPqH6iH44UZnzXiX9kbm1qyecjU3k5s0vtufLI7Yuz/a/uL37mEAqTMQplBBAlk0T9e2e1Dw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vega-typings/-/vega-typings-1.1.0.tgz", + "integrity": "sha512-uI6RWlMiGRhsgmw/LzJtjCc0kwhw2f0JpyNMTAnOy90kE4e4CiaZN5nJp8S9CcfcBoPEZHc166AOn2SSNrKn3A==", "dependencies": { "@types/geojson": "7946.0.4", "vega-event-selector": "^3.0.1", - "vega-expression": "^5.0.1", - "vega-util": "^1.17.1" + "vega-expression": "^5.1.0", + "vega-util": "^1.17.2" } }, "node_modules/vega-typings/node_modules/@types/geojson": { @@ -11496,9 +11509,9 @@ "integrity": "sha512-omNmGiZBdjm/jnHjZlywyYqafscDdHaELHx1q96n5UOz/FlO9JO99P4B3jZg391EFG8dqhWjQilSf2JH6F1mIw==" }, "node_modules/vega-view": { - "version": "5.11.1", - "resolved": "https://registry.npmjs.org/vega-view/-/vega-view-5.11.1.tgz", - "integrity": "sha512-RoWxuoEMI7xVQJhPqNeLEHCezudsf3QkVMhH5tCovBqwBADQGqq9iWyax3ZzdyX1+P3eBgm7cnLvpqtN2hU8kA==", + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/vega-view/-/vega-view-5.12.0.tgz", + "integrity": "sha512-T3GY7UJNVZGrCUrAmE/OCrkoJQyOT/2dCgXgy9EvDMVv/sdrn7o1TMKhSV18nIr0m5A7m4mgKwrmguAfROY85g==", "dependencies": { "d3-array": "^3.2.2", "d3-timer": "^3.0.1", @@ -11521,9 +11534,9 @@ } }, "node_modules/vega-voronoi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/vega-voronoi/-/vega-voronoi-4.2.1.tgz", - "integrity": "sha512-zzi+fxU/SBad4irdLLsG3yhZgXWZezraGYVQfZFWe8kl7W/EHUk+Eqk/eetn4bDeJ6ltQskX+UXH3OP5Vh0Q0Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/vega-voronoi/-/vega-voronoi-4.2.2.tgz", + "integrity": "sha512-Bq2YOp2MGphhQnUuLwl3dsyBs6MuEU86muTjDbBJg33+HkZtE1kIoQZr+EUHa46NBsY1NzSKddOTu8wcaFrWiQ==", "dependencies": { "d3-delaunay": "^6.0.2", "vega-dataflow": "^5.7.5", @@ -11563,11 +11576,12 @@ "dev": true }, "node_modules/vue": { - "version": "2.7.15", - "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.15.tgz", - "integrity": "sha512-a29fsXd2G0KMRqIFTpRgpSbWaNBK3lpCTOLuGLEDnlHWdjB8fwl6zyYZ8xCrqkJdatwZb4mGHiEfJjnw0Q6AwQ==", + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz", + "integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==", + "deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.", "dependencies": { - "@vue/compiler-sfc": "2.7.15", + "@vue/compiler-sfc": "2.7.16", "csstype": "^3.1.0" } }, @@ -11594,9 +11608,9 @@ } }, "node_modules/web-worker": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", - "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", + "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==" }, "node_modules/webidl-conversions": { "version": "3.0.1", @@ -11757,9 +11771,9 @@ } }, "node_modules/webpack/node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "bin": { "acorn": "bin/acorn" }, diff --git a/pyproject.toml b/pyproject.toml index f563024c2..f51823ab2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,23 @@ exclude = ''' )/ ''' +[tool.creosote] +venvs = [".venv"] +paths = ["funnel", "tests", "migrations/versions"] +deps-file = "requirements/base.in" +exclude-deps = [ + "argon2-cffi", # Optional dep for passlib + "bcrypt", # Optional dep for passlib + "greenlet", # Optional dep for SQLAlchemy's asyncio support + "gunicorn", # Not imported, used as server + "linkify-it-py", # Optional dep for markdown-it-py + "psycopg", # Optional dep for SQLAlchemy + "rq-dashboard", # Creosote fails to recognise the import + "tzdata", # Resource-only dep, no code to import + "urllib3", # Unused but required to silence a pip-audit warning + "wtforms-sqlalchemy", # Temp dep on an unreleased git branch +] + [tool.djlint] profile = 'jinja' extension = '.html.jinja2' @@ -139,8 +156,8 @@ ignore_missing_imports = true show_error_codes = true warn_unreachable = true warn_unused_ignores = true -warn_redundant_casts = false -check_untyped_defs = false +warn_redundant_casts = true +check_untyped_defs = true [[tool.mypy.overrides]] module = 'tests.*' diff --git a/requirements/base.in b/requirements/base.in index 8c78879a4..232b1f8b5 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -45,7 +45,7 @@ pycountry pyIsEmail python-dateutil python-dotenv -python-telegram-bot +python-telegram-bot>=20.7 pytz PyVimeo qrcode @@ -64,6 +64,6 @@ urllib3[socks] # Not direct dep; pip-audit complains of dupe urllib[socks] in t user-agents werkzeug whitenoise -wtforms-sqlalchemy @ git+https://github.com/jace/wtforms-sqlalchemy # See /pull/1 +wtforms-sqlalchemy z-base-32 zxcvbn diff --git a/requirements/base.txt b/requirements/base.txt index b3dedcadc..05360f085 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,4 @@ -# SHA1:61d921a696418138a9c8fbf50898b94a0a942f13 +# SHA1:af16bb123ee5c105566f3a83ba6f330975c48d6f # # This file is autogenerated by pip-compile-multi # To update, run: @@ -21,7 +21,7 @@ aiohttp-retry==2.8.3 # via twilio aiosignal==1.3.1 # via aiohttp -alembic==1.13.0 +alembic==1.13.1 # via # -r requirements/base.in # flask-migrate @@ -35,7 +35,7 @@ argon2-cffi-bindings==21.2.0 # via argon2-cffi arrow==1.3.0 # via rq-dashboard -attrs==23.1.0 +attrs==23.2.0 # via aiohttp babel==2.14.0 # via @@ -155,7 +155,7 @@ flask-wtf==1.2.1 # via # -r requirements/base.in # baseframe -freezegun==1.3.1 +freezegun==1.4.0 # via rq-scheduler frozenlist==1.4.1 # via @@ -228,7 +228,7 @@ joblib==1.3.2 # via nltk linkify-it-py==2.0.2 # via -r requirements/base.in -lxml==4.9.3 +lxml==5.0.1 # via premailer mako==1.3.0 # via alembic @@ -282,7 +282,7 @@ packaging==23.2 # marshmallow passlib==1.7.4 # via -r requirements/base.in -phonenumbers==8.13.26 +phonenumbers==8.13.27 # via -r requirements/base.in playwright==1.40.0 # via -r requirements/base.in @@ -290,9 +290,9 @@ premailer==3.10.0 # via -r requirements/base.in progressbar2==4.3.2 # via -r requirements/base.in -psycopg[binary]==3.1.15 +psycopg[binary]==3.1.17 # via -r requirements/base.in -psycopg-binary==3.1.15 +psycopg-binary==3.1.17 # via psycopg pyasn1==0.5.1 # via @@ -318,7 +318,7 @@ pyisemail==2.0.1 # mxsniff pyjwt==2.8.0 # via twilio -pymdown-extensions==10.5 +pymdown-extensions==10.7 # via coaster pyparsing==3.1.1 # via httplib2 @@ -365,7 +365,7 @@ redis==5.0.1 # rq-dashboard redis-sentinel-url==1.0.1 # via rq-dashboard -regex==2023.10.3 +regex==2023.12.25 # via nltk requests==2.31.0 # via @@ -420,7 +420,7 @@ sniffio==1.3.0 # via # anyio # httpx -sqlalchemy[asyncio]==2.0.23 +sqlalchemy[asyncio]==2.0.25 # via # -r requirements/base.in # alembic @@ -451,7 +451,7 @@ tweepy==4.14.0 # via -r requirements/base.in twilio==8.11.0 # via -r requirements/base.in -types-python-dateutil==2.8.19.14 +types-python-dateutil==2.8.19.20240106 # via arrow typing-extensions==4.9.0 # via @@ -467,7 +467,7 @@ typing-extensions==4.9.0 # typing-inspect typing-inspect==0.9.0 # via dataclasses-json -tzdata==2023.3 +tzdata==2023.4 # via -r requirements/base.in ua-parser==0.18.0 # via user-agents @@ -496,12 +496,12 @@ werkzeug==3.0.1 # flask whitenoise==6.6.0 # via -r requirements/base.in -wtforms==3.1.1 +wtforms==3.1.2 # via # baseframe # flask-wtf # wtforms-sqlalchemy -wtforms-sqlalchemy @ git+https://github.com/jace/wtforms-sqlalchemy +wtforms-sqlalchemy==0.4.1 # via # -r requirements/base.in # baseframe diff --git a/requirements/dev.txt b/requirements/dev.txt index ccb172374..9fe958b35 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -16,7 +16,7 @@ astroid==3.0.2 # via pylint bandit==1.7.6 # via -r requirements/dev.in -black==23.12.0 +black==23.12.1 # via -r requirements/dev.in build==1.0.3 # via pip-tools @@ -24,19 +24,21 @@ cattrs==22.1.0 # via reformat-gherkin cfgv==3.4.0 # via pre-commit +colorama==0.4.6 + # via djlint cssbeautifier==1.14.11 # via djlint dill==0.3.7 # via pylint distlib==0.3.8 # via virtualenv -djlint==1.34.0 +djlint==1.34.1 # via -r requirements/dev.in editorconfig==0.12.3 # via # cssbeautifier # jsbeautifier -flake8==6.1.0 +flake8==7.0.0 # via # -r requirements/dev.in # flake8-annotations @@ -106,13 +108,13 @@ mccabe==0.7.0 # via # flake8 # pylint -mypy==1.7.1 +mypy==1.8.0 # via -r requirements/dev.in -mypy-json-report==1.0.4 +mypy-json-report==1.1.0 # via -r requirements/dev.in nodeenv==1.8.0 # via pre-commit -pathspec==0.11.2 +pathspec==0.12.1 # via # black # djlint @@ -139,7 +141,7 @@ pycodestyle==2.11.1 # flake8-print pydocstyle==6.3.0 # via flake8-docstrings -pyflakes==3.1.0 +pyflakes==3.2.0 # via flake8 pylint==3.0.3 # via -r requirements/dev.in @@ -149,7 +151,7 @@ pyupgrade==3.15.0 # via -r requirements/dev.in reformat-gherkin==3.0.1 # via -r requirements/dev.in -ruff==0.1.8 +ruff==0.1.11 # via -r requirements/dev.in smmap==5.0.1 # via gitdb @@ -161,7 +163,7 @@ tokenize-rt==5.2.0 # via pyupgrade toposort==1.10 # via pip-compile-multi -types-chevron==0.14.2.5 +types-chevron==0.14.2.20240106 # via -r requirements/dev.in types-geoip2==3.0.0 # via -r requirements/dev.in @@ -169,19 +171,19 @@ types-ipaddress==1.0.8 # via types-maxminddb types-maxminddb==1.5.0 # via types-geoip2 -types-mock==5.1.0.3 +types-mock==5.1.0.20240106 # via -r requirements/dev.in -types-pyopenssl==23.3.0.0 +types-pyopenssl==23.3.0.20240106 # via types-redis types-pytz==2023.3.1.1 # via -r requirements/dev.in -types-redis==4.6.0.11 +types-redis==4.6.0.20240106 # via -r requirements/dev.in -types-requests==2.31.0.10 +types-requests==2.31.0.20240106 # via -r requirements/dev.in virtualenv==20.25.0 # via pre-commit -wcwidth==0.2.12 +wcwidth==0.2.13 # via reformat-gherkin wheel==0.42.0 # via pip-tools diff --git a/requirements/test.in b/requirements/test.in index 2e53a1c45..8685f2415 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -1,10 +1,8 @@ -r base.in beautifulsoup4 -colorama coverage coveralls lxml -Pygments pytest pytest-asyncio pytest-bdd diff --git a/requirements/test.txt b/requirements/test.txt index 5cd5ba219..a0f831626 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,4 +1,4 @@ -# SHA1:1e77cd95c84f71006a8b56e292c4046a43da0859 +# SHA1:53380f1a9e10714a4d23fbf55796f24dc0f348d8 # # This file is autogenerated by pip-compile-multi # To update, run: @@ -14,8 +14,6 @@ # baseframe beautifulsoup4==4.12.2 # via -r requirements/test.in -colorama==0.4.6 - # via -r requirements/test.in coverage[toml]==6.5.0 # via # -r requirements/test.in @@ -35,7 +33,7 @@ parse-type==0.6.2 # via pytest-bdd pluggy==1.3.0 # via pytest -pytest==7.4.3 +pytest==7.4.4 # via # -r requirements/test.in # pytest-asyncio @@ -47,7 +45,7 @@ pytest==7.4.3 # pytest-playwright # pytest-rerunfailures # pytest-socket -pytest-asyncio==0.23.2 +pytest-asyncio==0.23.3 # via -r requirements/test.in pytest-base-url==2.0.0 # via pytest-playwright diff --git a/runfrontendtests.sh b/runfrontendtests.sh index 158adaad7..753da125f 100755 --- a/runfrontendtests.sh +++ b/runfrontendtests.sh @@ -3,8 +3,11 @@ # Load config into environment variables set -o allexport source .flaskenv +# shellcheck disable=SC1091 source .env +# shellcheck disable=SC1091 source .testenv +# shellcheck disable=SC1091 source .env.testing set +o allexport @@ -12,9 +15,9 @@ set +o allexport set -o errexit python -m tests.cypress.cypress_initdb_test -flask run -p 3002 --no-reload --debugger 2>&1 1>/tmp/funnel-server.log & echo $! > /tmp/funnel-server.pid +flask run -p 3002 --no-reload --debugger 1>/tmp/funnel-server.log 2>&1 & echo $! > /tmp/funnel-server.pid function killserver() { - kill $(cat /tmp/funnel-server.pid) + kill "$(cat /tmp/funnel-server.pid)" python -m tests.cypress.cypress_dropdb_test rm /tmp/funnel-server.pid } diff --git a/tests/conftest.py b/tests/conftest.py index 49ac6d1a4..977326ba2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations +import os.path import re import time import warnings @@ -11,31 +12,49 @@ from dataclasses import dataclass from datetime import datetime, timezone from difflib import unified_diff +from dis import disassemble +from functools import partial +from inspect import stack as inspect_stack +from io import StringIO +from pprint import saferepr +from textwrap import indent from types import MethodType, ModuleType, SimpleNamespace -from typing import TYPE_CHECKING, Any, NamedTuple, get_type_hints +from typing import ( + TYPE_CHECKING, + Any, + NamedTuple, + Protocol, + get_type_hints, + runtime_checkable, +) from unittest.mock import patch -import flask_wtf.csrf +import flask import pytest import sqlalchemy as sa import sqlalchemy.exc as sa_exc import sqlalchemy.orm as sa_orm import typeguard -from flask import session +from flask import Flask, session +from flask.testing import FlaskClient +from flask.wrappers import Response from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy.session import Session as FsaSession +from flask_wtf.csrf import generate_csrf +from lxml.html import FormElement, HtmlElement, fromstring # nosec +from rich.console import Console +from rich.highlighter import RegexHighlighter, ReprHighlighter +from rich.markup import escape as rich_escape +from rich.syntax import Syntax +from rich.text import Text from sqlalchemy import event -from sqlalchemy.orm import Session as DatabaseSessionClass +from sqlalchemy.orm import Session as DatabaseSessionClass, scoped_session +from werkzeug import run_simple +from werkzeug.test import TestResponse if TYPE_CHECKING: - from flask import Flask - from flask.testing import FlaskClient - from rich.console import Console - from werkzeug.test import TestResponse - import funnel.models as funnel_models - # --- Pytest config -------------------------------------------------------------------- @@ -115,12 +134,16 @@ def pytest_runtest_call(item: pytest.Function) -> None: # get_type_hints may fail on Python <3.10 because pytest-bdd appears to have # `dict[str, str]` as a type somewhere, and builtin type subscripting isn't # supported yet - warnings.warn( - f"Type annotations could not be retrieved for {item.obj!r}", + warnings.warn( # noqa: B028 + f"Type annotations could not be retrieved for {item.obj.__qualname__}", RuntimeWarning, - stacklevel=1, ) return + except NameError as exc: + pytest.fail( + f"{item.obj.__qualname__} has an unknown annotation for {exc.name}." + " Is it imported within a TYPE_CHECKING test?" + ) for attr, type_ in annotations.items(): if attr in item.funcargs: @@ -165,9 +188,6 @@ def funnel_devtest() -> ModuleType: @pytest.fixture(scope='session') def response_with_forms() -> Any: # Since the actual return type is defined within - from flask.wrappers import Response - from lxml.html import FormElement, HtmlElement, fromstring # nosec - # --- ResponseWithForms, to make form submission in the test client testing easier # --- Adapted from the abandoned Flask-Fillin package @@ -302,107 +322,107 @@ def metarefresh(self) -> MetaRefreshContent | None: @pytest.fixture(scope='session') def rich_console() -> Console: - """Provide a rich console for color output.""" - from rich.console import Console - - return Console() + """Provide a rich console for colour output.""" + return Console(highlight=False) -@pytest.fixture(scope='session') -def colorama() -> Iterator[SimpleNamespace]: - """Provide the colorama print colorizer.""" - from colorama import Back, Fore, Style, deinit, init - - init() - yield SimpleNamespace(Fore=Fore, Back=Back, Style=Style) - deinit() +class PrintStackProtocol(Protocol): + def __call__(self, skip: int = 0, limit: int | None = None) -> None: + ... @pytest.fixture(scope='session') -def colorize_code(rich_console: Console) -> Callable[[str, str | None], str]: - """Return colorized output for a string of code, for current terminal's colors.""" - - def no_colorize(code_string: str, lang: str | None = 'python') -> str: - # Pygments is not available or terminal does not support colour output - return code_string - - try: - from pygments import highlight - from pygments.formatters import ( - Terminal256Formatter, - TerminalFormatter, - TerminalTrueColorFormatter, - ) - from pygments.lexers import get_lexer_by_name, guess_lexer - except ModuleNotFoundError: - return no_colorize - - if rich_console.color_system == 'truecolor': - formatter = TerminalTrueColorFormatter() - elif rich_console.color_system == '256': - formatter = Terminal256Formatter() - elif rich_console.color_system == 'standard': - formatter = TerminalFormatter() - else: - # color_system is `None` or `'windows'` or something unrecognised. No colours. - return no_colorize - - def colorize(code_string: str, lang: str | None = 'python') -> str: - if lang in (None, 'auto'): - lexer = guess_lexer(code_string) - else: - lexer = get_lexer_by_name(lang) - return highlight(code_string, lexer, formatter).rstrip() - - return colorize - - -@pytest.fixture(scope='session') -def print_stack(pytestconfig, colorama, colorize_code) -> Callable[[int, int], None]: +def print_stack(pytestconfig, rich_console: Console) -> PrintStackProtocol: """Print a stack trace up to an outbound call from within this repository.""" - import os.path - from inspect import stack as inspect_stack - boundary_path = str(pytestconfig.rootpath) if not boundary_path.endswith('/'): boundary_path += '/' - def func(skip: int = 0, indent: int = 2) -> None: + class PathHighlighter(RegexHighlighter): + highlights = [ + r'^(?=(?:\.\./|\.venv/))(?P.*)', + r'^(?!(?:\.\./|\.venv/))(?P.*)', + r'(?P.*/)(?P.+)', + ] + + def func(skip: int = 0, limit: int | None = None) -> None: # Retrieve call stack, removing ourselves and as many frames as the caller wants # to skip - prefix = ' ' * indent stack = inspect_stack()[2 + skip :] + try: + path_highlighter = PathHighlighter() + lines: list[Text | Syntax] = [] + # Reverse list to order from outermost to innermost, and remove outer frames + # that are outside our code + stack.reverse() + while stack and ( + stack[0].filename.startswith(boundary_path + '.venv/') + or not stack[0].filename.startswith(boundary_path) + ): + stack.pop(0) + + # Find the first exit from our code and keep only that line and later to + # remove unnecessary context. "Our code" = anything that is within + # boundary_path but not in a top-level `.venv` folder + for index, frame_info in enumerate(stack): + if stack[0].filename.startswith( + boundary_path + '.venv/' + ) or not frame_info.filename.startswith(boundary_path): + stack = stack[index - 1 :] + break + + for frame_info in stack[:limit]: + text = Text.assemble( + path_highlighter( + Text( + os.path.relpath(frame_info.filename, boundary_path), + style='pygments.string', + ) + ), + (':', 'pygments.text'), + (str(frame_info.lineno), 'pygments.number'), + (' in ', 'dim'), + (frame_info.function, 'pygments.function'), + ('\t', 'pygments.text'), + style='pygments.text', + ) + lines.append(text) + if frame_info.code_context: + lines.append( + Syntax( + '\n'.join( + [ + ' ' + line.strip() + for line in frame_info.code_context + ] + ), + 'python', + background_color='default', + word_wrap=True, + ) + ) + else: + dis_file = StringIO() + disassemble( + frame_info.frame.f_code, + lasti=frame_info.frame.f_lasti, + file=dis_file, + ) + lines.append( + Syntax( + indent(dis_file.getvalue().rstrip(), ' '), + 'python', # Code is Python bytecode, but this seems to work + background_color='default', + word_wrap=True, + ) + ) - lines = [] - # Reverse list to order from outermost to innermost, and remove outer frames - # that are outside our code - stack.reverse() - while stack and not stack[0].filename.startswith(boundary_path): - stack.pop(0) - - # Find the first exit from our code and keep only that line and later to - # remove unnecessary context - for index, fi in enumerate(stack): - if not fi.filename.startswith(boundary_path): - stack = stack[index - 1 :] - break - - for fi in stack: - line_color = ( - colorama.Fore.RED - if fi.filename.startswith(boundary_path) - else colorama.Fore.GREEN - ) - code_line = '\n'.join(fi.code_context or []).strip() - lines.append( - f'{prefix}{line_color}' - f'{os.path.relpath(fi.filename)}:{fi.lineno}::{fi.function}' - f'\t{colorize_code(code_line)}' - f'{colorama.Style.RESET_ALL}' - ) - del stack + if limit and limit < len(stack): + lines.append(Text.assemble((f'✂️ {len(stack)-limit} more…', 'dim'))) + finally: + del stack # Now print the lines - print(*lines, sep='\n') # noqa: T201 + rich_console.print(*lines) return func @@ -544,17 +564,13 @@ def _requires_config(request: pytest.FixtureRequest) -> None: @pytest.fixture(scope='session') -def _app_events(colorama, print_stack, app) -> Iterator: +def _app_events( + rich_console: Console, print_stack: PrintStackProtocol, app: Flask +) -> Iterator: """Fixture to report Flask signals with a stack trace when debugging a test.""" - from functools import partial - - import flask def signal_handler(signal_name, *args, **kwargs): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}Signal:{colorama.Style.NORMAL}" - f" {colorama.Fore.YELLOW}{signal_name}{colorama.Style.RESET_ALL}" - ) + rich_console.print(f"[bold]Signal:[/] [yellow]{rich_escape(signal_name)}[/]") print_stack(2) # Skip two stack frames from Blinker request_started = partial(signal_handler, 'request_started') @@ -582,7 +598,9 @@ def signal_handler(signal_name, *args, **kwargs): @pytest.fixture() -def _database_events(models, colorama, colorize_code, print_stack) -> Iterator: +def _database_events( + models: ModuleType, rich_console: Console, print_stack: PrintStackProtocol +) -> Iterator: """ Fixture to report database session events for debugging a test. @@ -592,98 +610,124 @@ def _database_events(models, colorama, colorize_code, print_stack) -> Iterator: def test_whatever() -> None: ... """ - from pprint import saferepr + repr_highlighter = ReprHighlighter() def safe_repr(entity): try: return saferepr(entity) except Exception: # noqa: B902 # pylint: disable=broad-except if hasattr(entity, '__class__'): - return f'{entity.__class__.__qualname__}(class-repr-error)' + return f'' + if hasattr(entity, '__qualname__'): + return f'' @event.listens_for(models.Model, 'init', propagate=True) def event_init(obj, args, kwargs): rargs = ', '.join(safe_repr(_a) for _a in args) rkwargs = ', '.join(f'{_k}={safe_repr(_v)}' for _k, _v in kwargs.items()) rparams = f'{rargs, rkwargs}' if rargs else rkwargs - code = colorize_code(f"{obj.__class__.__qualname__}({rparams})") - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: new:{colorama.Style.NORMAL}" f" {code}" + code = f'{obj.__class__.__qualname__}({rparams})' + rich_console.print( + Text.assemble(('obj:', 'bold'), ' new: ', repr_highlighter(code)) ) @event.listens_for(DatabaseSessionClass, 'transient_to_pending') def event_transient_to_pending(_session, obj): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: transient to pending:{colorama.Style.NORMAL}" - f" {colorize_code(safe_repr(obj))}" + rich_console.print( + Text.assemble( + ('obj:', 'bold'), + ' transient → pending: ', + repr_highlighter(safe_repr(obj)), + ) ) @event.listens_for(DatabaseSessionClass, 'pending_to_transient') def event_pending_to_transient(_session, obj): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: pending to transient:{colorama.Style.NORMAL}" - f" {colorize_code(safe_repr(obj))}" + rich_console.print( + Text.assemble( + ('obj:', 'bold'), + ' pending → transient: ', + repr_highlighter(safe_repr(obj)), + ) ) @event.listens_for(DatabaseSessionClass, 'pending_to_persistent') def event_pending_to_persistent(_session, obj): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: pending to persistent:{colorama.Style.NORMAL}" - f" {colorize_code(safe_repr(obj))}" + rich_console.print( + Text.assemble( + ('obj:', 'bold'), + ' pending → persistent: ', + repr_highlighter(safe_repr(obj)), + ) ) @event.listens_for(DatabaseSessionClass, 'loaded_as_persistent') def event_loaded_as_persistent(_session, obj): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: loaded as persistent:{colorama.Style.NORMAL}" - f" {safe_repr(obj)}" + rich_console.print( + Text.assemble( + ('obj:', 'bold'), + ' loaded as persistent: ', + repr_highlighter(safe_repr(obj)), + ) ) @event.listens_for(DatabaseSessionClass, 'persistent_to_transient') def event_persistent_to_transient(_session, obj): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: persistent to transient:" - f"{colorama.Style.NORMAL} {safe_repr(obj)}" + rich_console.print( + Text.assemble( + ('obj:', 'bold'), + ' persistent → transient: ', + repr_highlighter(safe_repr(obj)), + ) ) @event.listens_for(DatabaseSessionClass, 'persistent_to_deleted') def event_persistent_to_deleted(_session, obj): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: persistent to deleted:{colorama.Style.NORMAL}" - f" {safe_repr(obj)}" + rich_console.print( + Text.assemble( + ('obj:', 'bold'), + ' persistent → deleted: ', + repr_highlighter(safe_repr(obj)), + ) ) @event.listens_for(DatabaseSessionClass, 'deleted_to_detached') def event_deleted_to_detached(_session, obj): i = sa.inspect(obj) - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: deleted to detached:{colorama.Style.NORMAL}" - f" {obj.__class__.__qualname__}/{i.identity}" + rich_console.print( + "[bold]obj:[/] deleted → detached:" + f" {rich_escape(obj.__class__.__qualname__)}/{i.identity}" ) @event.listens_for(DatabaseSessionClass, 'persistent_to_detached') def event_persistent_to_detached(_session, obj): i = sa.inspect(obj) - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: persistent to detached:" - f"{colorama.Style.NORMAL} {obj.__class__.__qualname__}/{i.identity}" + rich_console.print( + "[bold]obj:[/] persistent → detached:" + f" {rich_escape(obj.__class__.__qualname__)}/{i.identity}" ) @event.listens_for(DatabaseSessionClass, 'detached_to_persistent') def event_detached_to_persistent(_session, obj): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: detached to persistent:" - f"{colorama.Style.NORMAL} {safe_repr(obj)}" + rich_console.print( + Text.assemble( + ('obj:', 'bold'), + ' detached → persistent: ', + repr_highlighter(safe_repr(obj)), + ) ) @event.listens_for(DatabaseSessionClass, 'deleted_to_persistent') def event_deleted_to_persistent(session, obj): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}obj: deleted to persistent:{colorama.Style.NORMAL}" - f" {safe_repr(obj)}" + rich_console.print( + Text.assemble( + ('obj:', 'bold'), + ' deleted → persistent: ', + repr_highlighter(safe_repr(obj)), + ) ) @event.listens_for(DatabaseSessionClass, 'do_orm_execute') @@ -706,101 +750,85 @@ def event_do_orm_execute(orm_execute_state): class_name = ( orm_execute_state.bind_mapper.class_.__qualname__ if orm_execute_state.bind_mapper - else None + else '' ) - print( # noqa: T201 - f"{colorama.Style.BRIGHT}exec:{colorama.Style.NORMAL} {class_name}:" - f" {', '.join(state_is)}" + rich_console.print( + Text.assemble( + ('exec: ', 'bold'), + ', '.join(state_is), + (' on ', 'dim'), + (class_name, 'repr.call'), + ) ) @event.listens_for(DatabaseSessionClass, 'after_begin') def event_after_begin(_session, transaction, _connection): if transaction.nested: if transaction.parent.nested: - print( # noqa: T201 - f"{colorama.Style.BRIGHT}session:{colorama.Style.NORMAL}" - f" BEGIN (double nested)" - ) + rich_console.print("[bold]session:[/] BEGIN (double nested)") else: - print( # noqa: T201 - f"{colorama.Style.BRIGHT}session:{colorama.Style.NORMAL}" - f" BEGIN (nested)" - ) + rich_console.print("[bold]session:[/] BEGIN (nested)") else: - print( # noqa: T201 - f"{colorama.Style.BRIGHT}session:{colorama.Style.NORMAL} BEGIN (outer)" - ) - print_stack() + rich_console.print("[bold]session:[/] BEGIN (outer)") + print_stack(0, 5) @event.listens_for(DatabaseSessionClass, 'after_commit') def event_after_commit(session): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}session:{colorama.Style.NORMAL} COMMIT" - f" ({session.info!r})" + rich_console.print( + Text.assemble( + ('session:', 'bold'), ' COMMIT ', repr_highlighter(repr(session.info)) + ) ) @event.listens_for(DatabaseSessionClass, 'after_flush') def event_after_flush(session, _flush_context): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}session:{colorama.Style.NORMAL} FLUSH" - f" ({session.info})" + rich_console.print( + Text.assemble( + ('session:', 'bold'), ' FLUSH ', repr_highlighter(repr(session.info)) + ) ) @event.listens_for(DatabaseSessionClass, 'after_rollback') def event_after_rollback(session): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}session:{colorama.Style.NORMAL} ROLLBACK" - f" ({session.info})" + rich_console.print( + Text.assemble( + ('session:', 'bold'), ' ROLLBACK ', repr_highlighter(repr(session.info)) + ) ) - print_stack() + print_stack(0, 5) @event.listens_for(DatabaseSessionClass, 'after_soft_rollback') def event_after_soft_rollback(session, _previous_transaction): - print( # noqa: T201 - f"{colorama.Style.BRIGHT}session:{colorama.Style.NORMAL} SOFT ROLLBACK" - f" ({session.info})" + rich_console.print( + Text.assemble( + ('session:', 'bold'), + ' SOFT ROLLBACK ', + repr_highlighter(repr(session.info)), + ) ) - print_stack() + print_stack(0, 5) @event.listens_for(DatabaseSessionClass, 'after_transaction_create') def event_after_transaction_create(_session, transaction): if transaction.nested: if transaction.parent.nested: - print( # noqa: T201 - f"{colorama.Style.BRIGHT}transaction:{colorama.Style.NORMAL}" - f" CREATE (savepoint)" - ) + rich_console.print("[bold]transaction:[/] CREATE (savepoint)") else: - print( # noqa: T201 - f"{colorama.Style.BRIGHT}transaction:{colorama.Style.NORMAL}" - f" CREATE (fixture)" - ) + rich_console.print("[bold]transaction:[/] CREATE (fixture)") else: - print( # noqa: T201 - f"{colorama.Style.BRIGHT}transaction:{colorama.Style.NORMAL}" - f" CREATE (db)" - ) - print_stack() + rich_console.print("[bold]transaction:[/] CREATE (db)") + print_stack(0, 5) @event.listens_for(DatabaseSessionClass, 'after_transaction_end') def event_after_transaction_end(_session, transaction): if transaction.nested: if transaction.parent.nested: - print( # noqa: T201 - f"{colorama.Style.BRIGHT}transaction:{colorama.Style.NORMAL} END" - f" (double nested)" - ) + rich_console.print("[bold]transaction:[/] END (double nested)") else: - print( # noqa: T201 - f"{colorama.Style.BRIGHT}transaction:{colorama.Style.NORMAL} END" - f" (nested)" - ) + rich_console.print("[bold]transaction:[/] END (nested)") else: - print( # noqa: T201 - f"{colorama.Style.BRIGHT}transaction:{colorama.Style.NORMAL} END" - f" (outer)" - ) - print_stack() + rich_console.print("[bold]transaction:[/] END (outer)") + print_stack(0, 5) yield @@ -918,7 +946,7 @@ def drop_tables(): @pytest.fixture() def db_session_truncate( funnel, app, database, app_context -) -> Iterator[DatabaseSessionClass]: +) -> Iterator[DatabaseSessionClass | scoped_session]: """Empty the database after each use of the fixture.""" yield database.session sa_orm.close_all_sessions() @@ -977,7 +1005,7 @@ def get_bind( @pytest.fixture() def db_session_rollback( funnel, app, database, app_context -) -> Iterator[DatabaseSessionClass]: +) -> Iterator[DatabaseSessionClass | scoped_session]: """Create a nested transaction for the test and rollback after.""" original_session = database.session @@ -1017,7 +1045,7 @@ def db_session_rollback( @pytest.fixture() -def db_session(request) -> DatabaseSessionClass: +def db_session(request) -> DatabaseSessionClass | scoped_session: """ Database session fixture. @@ -1052,8 +1080,6 @@ def db_session(request) -> DatabaseSessionClass: @pytest.fixture() def client(response_with_forms, app, db_session) -> FlaskClient: """Provide a test client that commits the db session before any action.""" - from flask.testing import FlaskClient - client: FlaskClient = FlaskClient(app, response_with_forms, use_cookies=True) client_open = client.open @@ -1068,8 +1094,6 @@ def commit_before_open(*args, **kwargs): @pytest.fixture(scope='session') def live_server(funnel_devtest, app, database): """Run application in a separate process.""" - from werkzeug import run_simple - # Use HTTPS for live server (set to False if required) use_https = True scheme = 'https' if use_https else 'http' @@ -1123,7 +1147,7 @@ def csrf_token(app, client) -> str: """Supply a CSRF token for use in form submissions.""" field_name = app.config.get('WTF_CSRF_FIELD_NAME', 'csrf_token') with app.test_request_context(): - token = flask_wtf.csrf.generate_csrf() + token = generate_csrf() assert field_name in session session_token = session[field_name] with client.session_transaction() as client_session: @@ -1131,8 +1155,17 @@ def csrf_token(app, client) -> str: return token +@runtime_checkable +class LoginFixtureProtocol(Protocol): + def as_(self, user: funnel_models.User) -> None: + ... + + def logout(self) -> None: + ... + + @pytest.fixture() -def login(app, client, db_session) -> SimpleNamespace: +def login(app, client, db_session) -> LoginFixtureProtocol: """Provide a login fixture.""" def as_(user) -> None: @@ -1151,7 +1184,9 @@ def logout() -> None: client.server_name, 'lastuser', domain=app.config['LASTUSER_COOKIE_DOMAIN'] ) - return SimpleNamespace(as_=as_, logout=logout) + return SimpleNamespace( # pyright:ignore[reportGeneralTypeIssues] + as_=as_, logout=logout + ) # --- Sample data: users, organizations, projects, etc --------------------------------- diff --git a/tests/e2e/account/register_test.py b/tests/e2e/account/register_test.py index 65b18aac9..0d4d4baee 100644 --- a/tests/e2e/account/register_test.py +++ b/tests/e2e/account/register_test.py @@ -28,7 +28,7 @@ def published_project(db_session, new_project: models.Project) -> models.Project: """Published project fixture.""" new_project.publish() - new_project.rsvp_state = models.PROJECT_RSVP_STATE.ALL + new_project.rsvp_state = models.ProjectRsvpStateEnum.ALL db_session.commit() return new_project diff --git a/tests/integration/views/label_views_test.py b/tests/integration/views/label_views_test.py index 0f83bf7b3..e4f4553b8 100644 --- a/tests/integration/views/label_views_test.py +++ b/tests/integration/views/label_views_test.py @@ -1,13 +1,23 @@ """Test Label views.""" import pytest +from flask import Flask +from flask.testing import FlaskClient from funnel import models +from ...conftest import LoginFixtureProtocol + @pytest.mark.dbcommit() def test_manage_labels_view( - app, client, login, new_project, new_user, new_label, new_main_label + app: Flask, + client: FlaskClient, + login: LoginFixtureProtocol, + new_project: models.Project, + new_user: models.User, + new_label: models.Label, + new_main_label: models.Label, ) -> None: login.as_(new_user) resp = client.get(new_project.url_for('labels')) @@ -17,37 +27,15 @@ def test_manage_labels_view( @pytest.mark.dbcommit() -def test_edit_option_label_view(app, client, login, new_user, new_main_label) -> None: +def test_edit_option_label_view( + app: Flask, + client: FlaskClient, + login: LoginFixtureProtocol, + new_user: models.User, + new_main_label: models.Label, +) -> None: login.as_(new_user) opt_label = new_main_label.options[0] resp = client.post(opt_label.url_for('edit'), follow_redirects=True) assert "Manage labels" in resp.data.decode('utf-8') assert "Only main labels can be edited" in resp.data.decode('utf-8') - - -@pytest.mark.xfail(reason="Broken by Flask-SQLAlchemy 3.0, unclear why") # FIXME -def test_main_label_delete(client, login, new_user, new_label) -> None: - login.as_(new_user) - resp = client.post(new_label.url_for('delete'), follow_redirects=True) - assert "Manage labels" in resp.data.decode('utf-8') - assert "The label has been deleted" in resp.data.decode('utf-8') - label = models.Label.query.get(new_label.id) - assert label is None - - -@pytest.mark.xfail(reason="Broken after Flask-SQLAlchemy 3.0, unclear why") # FIXME -def test_optioned_label_delete(client, login, new_user, new_main_label) -> None: - login.as_(new_user) - label_a1 = new_main_label.options[0] - label_a2 = new_main_label.options[1] - - # let's delete the main optioned label - resp = client.post(new_main_label.url_for('delete'), follow_redirects=True) - assert "Manage labels" in resp.data.decode('utf-8') - assert "The label has been deleted" in resp.data.decode('utf-8') - mlabel = models.Label.query.get(new_main_label.id) - assert mlabel is None - - # so the option labels should have been deleted as well - for olabel in [label_a1, label_a2]: - assert models.Label.query.get(olabel.id) is None diff --git a/tests/integration/views/login_test.py b/tests/integration/views/login_test.py index 8cdeeab0d..10cd0ba04 100644 --- a/tests/integration/views/login_test.py +++ b/tests/integration/views/login_test.py @@ -10,9 +10,9 @@ from flask import redirect, request, session from werkzeug.datastructures import MultiDict -from coaster.auth import current_auth from coaster.utils import utcnow +from funnel.auth import current_auth from funnel.registry import LoginCallbackError, LoginInitError, LoginProviderData from funnel.transports import TransportConnectionError, TransportRecipientError from funnel.views.otp import OtpSession diff --git a/tests/unit/models/account_Organization_test.py b/tests/unit/models/account_Organization_test.py index b62b7098a..53e02c85e 100644 --- a/tests/unit/models/account_Organization_test.py +++ b/tests/unit/models/account_Organization_test.py @@ -1,6 +1,5 @@ """Tests for UserExternalId model.""" -from typing import cast import pytest @@ -30,7 +29,7 @@ def test_organization_get(db_session, user_twoflower) -> None: with pytest.raises(TypeError): models.Organization.get() # type: ignore[call-overload] # scenario 2: when buid is passed - buid = cast(str, org.buid) + buid = org.buid get_by_buid = models.Organization.get(buid=buid) assert get_by_buid == org # scenario 3: when username is passed diff --git a/tests/unit/models/account_profile_test.py b/tests/unit/models/account_profile_test.py index 00d2c780b..966a3923c 100644 --- a/tests/unit/models/account_profile_test.py +++ b/tests/unit/models/account_profile_test.py @@ -32,7 +32,7 @@ def test_user_avatar(db_session, user_twoflower, user_rincewind) -> None: assert user_rincewind.logo_url is None db_session.commit() - # Now test avatar is Optional[ImgeeFurl] + # Now test avatar is ImgeeFurl | None assert user_twoflower.logo_url is None assert user_rincewind.logo_url is None diff --git a/tests/unit/models/email_address_test.py b/tests/unit/models/email_address_test.py index 61949672d..1e5435392 100644 --- a/tests/unit/models/email_address_test.py +++ b/tests/unit/models/email_address_test.py @@ -425,7 +425,7 @@ class EmailLink(models.EmailAddressMixin, models.BaseMixin, models.Model): __email_is_exclusive__ = True emailuser_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('test_email_user.id'), nullable=False + sa.ForeignKey('test_email_user.id'), default=None, nullable=False ) emailuser: Mapped[EmailUser] = relationship() @@ -445,7 +445,7 @@ class EmailLinkedDocument( __email_for__ = 'emailuser' emailuser_id: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('test_email_user.id'), nullable=True + sa.ForeignKey('test_email_user.id'), default=None, nullable=True ) emailuser: Mapped[EmailUser | None] = relationship() diff --git a/tests/unit/models/helpers_test.py b/tests/unit/models/helpers_test.py index 985d0b5be..52439ba2d 100644 --- a/tests/unit/models/helpers_test.py +++ b/tests/unit/models/helpers_test.py @@ -1,12 +1,14 @@ """Tests for model helpers.""" # pylint: disable=possibly-unused-variable,redefined-outer-name +from collections.abc import Callable from types import SimpleNamespace +from typing import LiteralString, cast import pytest import sqlalchemy as sa import sqlalchemy.orm as sa_orm -from flask_babel import lazy_gettext +from flask_babel import lazy_gettext as lazy_gettext_base from furl import furl from sqlalchemy.exc import StatementError from sqlalchemy.orm import Mapped @@ -14,6 +16,8 @@ import funnel.models.helpers as mhelpers from funnel import models +lazy_gettext = cast(Callable[[LiteralString], str], lazy_gettext_base) + def test_valid_name() -> None: """Names are lowercase and contain letters, numbers and non-terminal hyphens.""" @@ -282,7 +286,7 @@ def test_quote_autocomplete_tsquery(db_session, prefix, tsquery) -> None: def test_message_composite() -> None: - """Test mhelpers.MessageComposite has similar properties to MarkdownComposite.""" + """Test MessageComposite has similar properties to MarkdownComposite.""" text1 = mhelpers.MessageComposite("Text1") assert text1.text == "Text1" assert text1.html == "

Text1

" diff --git a/tests/unit/models/phone_number_test.py b/tests/unit/models/phone_number_test.py index 57edad9dd..959935203 100644 --- a/tests/unit/models/phone_number_test.py +++ b/tests/unit/models/phone_number_test.py @@ -62,7 +62,7 @@ class PhoneLink(models.PhoneNumberMixin, models.BaseMixin, models.Model): __phone_is_exclusive__ = True phoneuser_id: Mapped[int] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('test_phone_user.id'), nullable=False + sa.ForeignKey('test_phone_user.id'), default=None, nullable=False ) phoneuser: Mapped[PhoneUser] = relationship() @@ -82,7 +82,7 @@ class PhoneLinkedDocument( __phone_for__ = 'phoneuser' phoneuser_id: Mapped[int | None] = sa_orm.mapped_column( - sa.Integer, sa.ForeignKey('test_phone_user.id'), nullable=True + sa.ForeignKey('test_phone_user.id'), default=None, nullable=True ) phoneuser: Mapped[PhoneUser | None] = relationship() diff --git a/tests/unit/models/project_crew_membership_test.py b/tests/unit/models/project_crew_membership_test.py index 40c75b7dc..13248b530 100644 --- a/tests/unit/models/project_crew_membership_test.py +++ b/tests/unit/models/project_crew_membership_test.py @@ -38,7 +38,7 @@ def test_project_crew_membership( assert 'editor' in new_project.roles_for(new_user_owner) assert new_membership.is_active assert new_membership in new_project.active_crew_memberships - assert new_membership.record_type == models.MEMBERSHIP_RECORD_TYPE.DIRECT_ADD + assert new_membership.record_type == models.MembershipRecordTypeEnum.DIRECT_ADD # only one membership can be active for a user at a time. # so adding a new membership without revoking the previous one @@ -86,7 +86,7 @@ def test_project_crew_membership( assert 'editor' in new_project.roles_for(new_user) assert 'promoter' not in new_project.roles_for(new_user) assert 'usher' not in new_project.roles_for(new_user) - assert new_membership3.record_type == models.MEMBERSHIP_RECORD_TYPE.AMEND + assert new_membership3.record_type == models.MembershipRecordTypeEnum.AMEND # replace() can replace a single role as well, rest stays as they were new_membership4 = new_membership3.replace(actor=new_user_owner, is_usher=True) @@ -101,7 +101,7 @@ def test_project_crew_membership( 'editor', 'usher', } - assert new_membership4.record_type == models.MEMBERSHIP_RECORD_TYPE.AMEND + assert new_membership4.record_type == models.MembershipRecordTypeEnum.AMEND # can't replace with an unknown role with pytest.raises(AttributeError): diff --git a/tests/unit/utils/markdown/data/vega-lite.toml b/tests/unit/utils/markdown/data/vega-lite.toml index a2aed9168..7cab069fc 100644 --- a/tests/unit/utils/markdown/data/vega-lite.toml +++ b/tests/unit/utils/markdown/data/vega-lite.toml @@ -352,11 +352,11 @@ markdown = """ """ [config] -profiles = [ "basic", "document",] +profiles = ["basic", "document"] [config.custom_profiles.vega_lite] preset = "default" -plugins = [ "vega_lite",] +plugins = ["vega_lite"] [expected_output] basic = """

vega-lite tests

@@ -1031,7 +1031,7 @@ document = """

vega-lite
""" -vega-lite = """

vega-lite tests

+vega_lite = """

vega-lite tests

Interactive Scatter Plot Matrix

Visualization
{
 "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
diff --git a/tests/unit/utils/markdown/markdown_test.py b/tests/unit/utils/markdown/markdown_test.py
index 5d2c198dd..ee61d6008 100644
--- a/tests/unit/utils/markdown/markdown_test.py
+++ b/tests/unit/utils/markdown/markdown_test.py
@@ -1,7 +1,5 @@
 """Tests for markdown parser."""
 
-import warnings
-
 import pytest
 from markupsafe import Markup
 
@@ -28,8 +26,7 @@ def test_markdown_cases(
 ) -> None:
     case = markdown_test_registry.test_case(md_testname, md_configname)
     if case.expected_output is None:
-        warnings.warn(f'Expected output not generated for {case}', stacklevel=1)
-        pytest.skip(f'Expected output not generated for {case}')
+        pytest.fail(f'Expected output not known for {case}')
 
     assert case.expected_output == case.output
 
diff --git a/tests/unit/views/notifications/organization_membership_notification_test.py b/tests/unit/views/notifications/organization_membership_notification_test.py
index b5c8bed55..9dfd9f715 100644
--- a/tests/unit/views/notifications/organization_membership_notification_test.py
+++ b/tests/unit/views/notifications/organization_membership_notification_test.py
@@ -3,7 +3,7 @@
 from pytest_bdd import given, parsers, scenarios, then, when
 
 from funnel import models
-from funnel.models.membership_mixin import MEMBERSHIP_RECORD_TYPE
+from funnel.models.membership_mixin import MembershipRecordTypeEnum
 
 scenarios('notifications/organization_membership_notification.feature')
 
@@ -110,7 +110,7 @@ def when_vetinari_invites_ridcully(
         account=org_ankhmorpork,
         granted_by=user_vetinari,
         is_owner=is_owner,
-        record_type=MEMBERSHIP_RECORD_TYPE.INVITE,
+        record_type=MembershipRecordTypeEnum.INVITE,
     )
     db_session.add(ridcully_admin)
     db_session.commit()
@@ -126,7 +126,7 @@ def when_ridcully_accepts_invite(
     ridcully_admin,
     user_ridcully,
 ) -> models.ProjectMembership:
-    assert ridcully_admin.record_type == MEMBERSHIP_RECORD_TYPE.INVITE
+    assert ridcully_admin.record_type == MembershipRecordTypeEnum.INVITE
     assert ridcully_admin.member == user_ridcully
     ridcully_admin_accept = ridcully_admin.accept(actor=user_ridcully)
     db_session.commit()
diff --git a/tests/unit/views/notifications/project_crew_notification_test.py b/tests/unit/views/notifications/project_crew_notification_test.py
index 299c8a248..3360aa23b 100644
--- a/tests/unit/views/notifications/project_crew_notification_test.py
+++ b/tests/unit/views/notifications/project_crew_notification_test.py
@@ -3,7 +3,7 @@
 from pytest_bdd import given, parsers, scenarios, then, when
 
 from funnel import models
-from funnel.models.membership_mixin import MEMBERSHIP_RECORD_TYPE
+from funnel.models.membership_mixin import MembershipRecordTypeEnum
 
 scenarios('notifications/project_crew_notification.feature')
 
@@ -142,7 +142,7 @@ def when_vetinari_invites_ridcully(
         parent=project_expo2010,
         member=user_ridcully,
         granted_by=user_vetinari,
-        record_type=MEMBERSHIP_RECORD_TYPE.INVITE,
+        record_type=MembershipRecordTypeEnum.INVITE,
         **role_columns(role),
     )
     db_session.add(ridcully_member)
@@ -160,7 +160,7 @@ def when_ridcully_accepts_invite(
     ridcully_member,
     user_ridcully,
 ) -> models.ProjectMembership:
-    assert ridcully_member.record_type == MEMBERSHIP_RECORD_TYPE.INVITE
+    assert ridcully_member.record_type == MembershipRecordTypeEnum.INVITE
     assert ridcully_member.member == user_ridcully
     ridcully_member_accept = ridcully_member.accept(actor=user_ridcully)
     db_session.commit()
diff --git a/tests/unit/views/rsvp_test.py b/tests/unit/views/rsvp_test.py
index bffc747da..00e4d8ad5 100644
--- a/tests/unit/views/rsvp_test.py
+++ b/tests/unit/views/rsvp_test.py
@@ -4,10 +4,14 @@
 import datetime
 
 import pytest
+from flask import Flask
+from flask.testing import FlaskClient
 from werkzeug.datastructures import MultiDict
 
 from funnel import models
 
+from ...conftest import LoginFixtureProtocol
+
 valid_schema = {
     'fields': [
         {
@@ -80,9 +84,9 @@ def project_expo2010(project_expo2010: models.Project) -> models.Project:
 
 # Organizer side testing
 def test_valid_registration_form_schema(
-    app,
-    client,
-    login,
+    app: Flask,
+    client: FlaskClient,
+    login: LoginFixtureProtocol,
     csrf_token: str,
     user_vetinari: models.User,
     project_expo2010: models.Project,
@@ -96,7 +100,7 @@ def test_valid_registration_form_schema(
             {
                 'org': '',
                 'item_collection_id': '',
-                'rsvp_state': models.PROJECT_RSVP_STATE.ALL,
+                'rsvp_state': int(models.ProjectRsvpStateEnum.ALL),
                 'is_subscription': False,
                 'register_button_txt': 'Follow',
                 'register_form_schema': app.json.dumps(valid_schema),
@@ -108,8 +112,8 @@ def test_valid_registration_form_schema(
 
 
 def test_invalid_registration_form_schema(
-    client,
-    login,
+    client: FlaskClient,
+    login: LoginFixtureProtocol,
     csrf_token: str,
     user_vetinari: models.User,
     project_expo2010: models.Project,
@@ -130,9 +134,9 @@ def test_invalid_registration_form_schema(
 
 
 def test_valid_json_register(
-    app,
-    client,
-    login,
+    app: Flask,
+    client: FlaskClient,
+    login: LoginFixtureProtocol,
     csrf_token: str,
     user_twoflower: models.User,
     project_expo2010: models.Project,
@@ -157,9 +161,9 @@ def test_valid_json_register(
 
 
 def test_valid_encoded_json_register(
-    app,
-    client,
-    login,
+    app: Flask,
+    client: FlaskClient,
+    login: LoginFixtureProtocol,
     csrf_token: str,
     user_twoflower: models.User,
     project_expo2010: models.Project,
@@ -181,7 +185,10 @@ def test_valid_encoded_json_register(
 
 
 def test_invalid_json_register(
-    client, login, user_twoflower: models.User, project_expo2010: models.Project
+    client: FlaskClient,
+    login: LoginFixtureProtocol,
+    user_twoflower: models.User,
+    project_expo2010: models.Project,
 ) -> None:
     """If a registration form is not JSON, it is rejected."""
     login.as_(user_twoflower)
diff --git a/tests/unit/views/session_temp_vars_test.py b/tests/unit/views/session_temp_vars_test.py
index 2ceed93ea..0963bec9f 100644
--- a/tests/unit/views/session_temp_vars_test.py
+++ b/tests/unit/views/session_temp_vars_test.py
@@ -47,8 +47,8 @@ def test_session_intersection() -> None:
     fake_session_intersection = {'test': 'value', 'other': 'other_value'}
     fake_session_disjoint = {'other': 'other_value', 'yet_other': 'yet_other_value'}
 
-    assert st.has_intersection(fake_session_intersection)
-    assert not st.has_intersection(fake_session_disjoint)
+    assert st.has_overlap_with(fake_session_intersection)
+    assert not st.has_overlap_with(fake_session_disjoint)
 
 
 @pytest.fixture()