diff --git a/auth0/client.py b/auth0/client.py index 8f8a53a7..4f67cd5f 100644 --- a/auth0/client.py +++ b/auth0/client.py @@ -38,7 +38,7 @@ class UsersWithTotals(BaseModel): """ Response from Auth0 users API when include_totals is True. - :var start: 0-based page number + :var start: index of the first item :var limit: number of items per page :var total: total number of items """ @@ -107,6 +107,7 @@ def get_users(self, page: Optional[int] = None, per_page: Optional[int] = None, params["search_engine"] = "v3" url = f"https://{self.domain}/api/v2/users" resp = self._client.get(url, params=params) + resp.raise_for_status() if include_totals: return UsersWithTotals(**resp.json()) return self._convert_users(resp) diff --git a/db/admin.py b/db/admin.py index 827e38dc..21468cf5 100644 --- a/db/admin.py +++ b/db/admin.py @@ -78,7 +78,7 @@ class BiocommonsUserAdmin(ModelView, model=BiocommonsUser): can_edit = False can_create = False can_delete = True - column_list = ["id", "username", "email", "created_at"] + column_list = ["id", "username", "email", "email_verified", "created_at"] column_default_sort = ("created_at", True) diff --git a/db/models.py b/db/models.py index 1bf78c04..781e6202 100644 --- a/db/models.py +++ b/db/models.py @@ -26,6 +26,7 @@ class BiocommonsUser(BaseModel, table=True): # Note: sqlmodel can't validate emails easily. # Use a separate data model to validate this email: str = Field(unique=True) + email_verified: bool = Field(default=False, nullable=False) username: str = Field(unique=True) created_at: AwareDatetime = Field( default_factory=lambda: datetime.now(timezone.utc), sa_type=DateTime @@ -53,7 +54,7 @@ def from_auth0_data(cls, data: 'schemas.biocommons.Auth0UserData') -> Self: """ Create a new BiocommonsUser object from Auth0 user data (no API call). """ - return cls(id=data.user_id, email=data.email, username=data.username) + return cls(id=data.user_id, email=data.email, username=data.username, email_verified=data.email_verified) @classmethod def get_or_create( @@ -69,6 +70,22 @@ def get_or_create( db_session.commit() return user + def update_from_auth0(self, auth0_id: str, auth0_client: Auth0Client) -> Self: + """ + Fetch user data from Auth0 and update this object with it. + Currently only updates email_verified. + """ + user_data = auth0_client.get_user(user_id=auth0_id) + return self.update_from_auth0_data(user_data) + + def update_from_auth0_data(self, data: 'schemas.biocommons.Auth0UserData') -> Self: + """ + Update this object with data from Auth0, without fetching. + Currently only updates email_verified. + """ + self.email_verified = data.email_verified + return self + def add_platform_membership( self, platform: PlatformEnum, db_session: Session, auto_approve: bool = False ) -> "PlatformMembership": @@ -90,7 +107,7 @@ def add_group_membership( membership = GroupMembership( group_id=group_id, user_id=self.id, - approval_status=ApprovalStatusEnum.PENDING, + approval_status=ApprovalStatusEnum.APPROVED if auto_approve else ApprovalStatusEnum.PENDING, updated_by_id=None, ) db_session.add(membership) diff --git a/db/setup.py b/db/setup.py index 605650f8..72e1923a 100644 --- a/db/setup.py +++ b/db/setup.py @@ -17,7 +17,13 @@ def get_engine(): global _engine if _engine is None: db_url, db_connect_args = get_db_config() - _engine = create_engine(db_url, connect_args=db_connect_args) + _engine = create_engine( + db_url, + connect_args=db_connect_args, + pool_size=20, + max_overflow=20, + pool_timeout=60, + ) return _engine diff --git a/migrations/versions/1546c07b9d78_user_email_verified.py b/migrations/versions/1546c07b9d78_user_email_verified.py new file mode 100644 index 00000000..d042ba67 --- /dev/null +++ b/migrations/versions/1546c07b9d78_user_email_verified.py @@ -0,0 +1,31 @@ +"""user_email_verified + +Revision ID: 1546c07b9d78 +Revises: a8cb5fd2d258 +Create Date: 2025-09-15 11:37:09.829832 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '1546c07b9d78' +down_revision: Union[str, None] = 'a8cb5fd2d258' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('biocommons_user', sa.Column('email_verified', sa.Boolean(), server_default=sa.false(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('biocommons_user', 'email_verified') + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 22fa1b99..64c03a26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,12 +16,15 @@ dependencies = [ "authlib>=1.6.1", "sqladmin>=0.21.0", "itsdangerous>=2.2.0", + "apscheduler[redis,sqlalchemy]~=3.11", + "loguru>=0.7.3", ] [project.optional-dependencies] dev = [ "pytest>=8.3.5", "pytest-mock>=3.14.0", + "pytest-asyncio~=1.2", "pytest-cov>=4.1.0", "ruff>=0.4.4", "polyfactory>=2.21.0", diff --git a/run_scheduler.py b/run_scheduler.py new file mode 100644 index 00000000..ff01d80f --- /dev/null +++ b/run_scheduler.py @@ -0,0 +1,47 @@ +import asyncio +import signal +import sys + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger +from loguru import logger + +from scheduled_tasks.scheduler import SCHEDULER +from scheduled_tasks.tasks import sync_auth0_users + + +def schedule_jobs(scheduler: AsyncIOScheduler): + hourly_trigger = IntervalTrigger(minutes=60) + logger.info("Adding job: sync_auth0_users") + scheduler.add_job( + sync_auth0_users, + trigger=hourly_trigger, + id="sync_auth0_users", + replace_existing=True + ) + + +async def main(): + logger.info("Setting up scheduler") + schedule_jobs(SCHEDULER) + logger.info("Starting scheduler") + SCHEDULER.start() + logger.info("Scheduler started, waiting for shutdown...") + # Wait for shutdown + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop.set) + await stop.wait() + logger.info("Stopping scheduler") + SCHEDULER.shutdown(wait=False) + + +if __name__ == "__main__": + logger.remove() + logger.add( + sys.stderr, + format="{time:YYYY-MM-DD HH:mm:ss}\t{level}\t{message}\t{name}\t{extra}", + level="INFO" + ) + asyncio.run(main()) diff --git a/scheduled_tasks/__init__.py b/scheduled_tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scheduled_tasks/scheduler.py b/scheduled_tasks/scheduler.py new file mode 100644 index 00000000..88cc6553 --- /dev/null +++ b/scheduled_tasks/scheduler.py @@ -0,0 +1,45 @@ +from apscheduler.events import ( + EVENT_JOB_ERROR, + EVENT_JOB_EXECUTED, + EVENT_JOB_MISSED, + JobExecutionEvent, +) +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from loguru import logger + + +def job_listener(event: JobExecutionEvent): + with logger.contextualize(job_id=event.job_id, run_time=getattr(event, "scheduled_run_time", None)): + if event.code == EVENT_JOB_EXECUTED: + logger.info("job executed successfully") + elif event.code == EVENT_JOB_ERROR: + logger.error("job failed: {event.exception}\n{event.traceback}") + elif event.code == EVENT_JOB_MISSED: + logger.warning("job missed its run time") + + +def create_scheduler(): + from db.setup import get_db_config + db_url, _ = get_db_config() + jobstores = { + "default": SQLAlchemyJobStore(url=db_url) + } + executors = { + "default": {"type": "asyncio"}, + } + scheduler = AsyncIOScheduler( + jobstores=jobstores, + executors=executors, + job_defaults={ + "misfire_grace_time": 5 * 60, + "coalesce": True, + }, + timezone="UTC" + ) + scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_MISSED) + return scheduler + + +# Only want to create the scheduler once +SCHEDULER = create_scheduler() diff --git a/scheduled_tasks/tasks.py b/scheduled_tasks/tasks.py new file mode 100644 index 00000000..4b926510 --- /dev/null +++ b/scheduled_tasks/tasks.py @@ -0,0 +1,45 @@ +from loguru import logger + +from auth.management import get_management_token +from auth0.client import Auth0Client +from config import get_settings +from db.models import BiocommonsUser +from db.setup import get_db_session +from scheduled_tasks.scheduler import SCHEDULER +from schemas.biocommons import Auth0UserData + + +async def sync_auth0_users(): + logger.info("Setting up Auth0 client") + settings = get_settings() + token = get_management_token(settings=settings) + auth0_client = Auth0Client(domain=settings.auth0_domain, management_token=token) + current_page = 1 + logger.info("Fetching users") + users = auth0_client.get_users(page=current_page, per_page=50, include_totals=True) + while True: + for user in users.users: + SCHEDULER.add_job(update_auth0_user, args=[user], id=f"update_user_{user.user_id}", replace_existing=True) + current_fetched = users.start + len(users.users) + if current_fetched >= users.total: + break + current_page += 1 + logger.info(f"Fetching page {current_page}") + users = auth0_client.get_users(page=current_page, per_page=50, include_totals=True) + + +async def update_auth0_user(user_data: Auth0UserData): + logger.info(f"Checking user {user_data.user_id}") + session = next(get_db_session()) + db_user = session.get(BiocommonsUser, user_data.user_id) + if db_user is None: + logger.info(" User not found in DB") + return False + db_user.update_from_auth0_data(user_data) + if session.is_modified(db_user): + logger.info(" User data changed, updating in DB") + else: + logger.info(" User data unchanged") + # Should be OK to commit as SQLAlchemy will only update modified fields + session.commit() + return True diff --git a/tests/conftest.py b/tests/conftest.py index 763ea289..2bc2ad5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,6 +96,7 @@ def disable_db_setup(mocker): by test fixtures """ mocker.patch("db.setup.create_db_and_tables", return_value=None) + mocker.patch("db.setup.get_engine", return_value=None) @pytest.fixture diff --git a/tests/datagen.py b/tests/datagen.py index 13ecb279..dd50a2f9 100644 --- a/tests/datagen.py +++ b/tests/datagen.py @@ -7,7 +7,7 @@ from polyfactory.factories.pydantic_factory import ModelFactory from pydantic import TypeAdapter, ValidationError -from auth0.client import EmailVerificationResponse +from auth0.client import EmailVerificationResponse, UsersWithTotals from schemas.biocommons import ( ALLOWED_SPECIAL_CHARS, Auth0UserData, @@ -137,3 +137,19 @@ def bundle(cls) -> str: class EmailVerificationResponseFactory(ModelFactory[EmailVerificationResponse]): ... + + +class UsersWithTotalsFactory(ModelFactory[UsersWithTotals]): + """ + Factory for generating Auth0 users API response. + It's tricky to define this factory so total/start/limit always match, best + to define them manually in each test. + """ + total = 20 + limit = 10 + start = 0 + + @post_generated + @classmethod + def users(cls, limit: int) -> list[Auth0UserData]: + return Auth0UserDataFactory.batch(size=limit) diff --git a/tests/db/test_db_admin.py b/tests/db/test_db_admin.py index 4a999adb..aa7b75a2 100644 --- a/tests/db/test_db_admin.py +++ b/tests/db/test_db_admin.py @@ -81,10 +81,11 @@ def test_admin_auth_authenticate_empty_roles_list_redirects(mock_settings): assert result == "redirect_response" -def test_admin_panel_access_with_valid_admin_session(test_client, mock_settings): +def test_admin_panel_access_with_valid_admin_session(test_client, mock_settings, test_db_engine): """Test that admin panel is accessible with valid admin session""" with patch("db.admin.get_settings", return_value=mock_settings), \ - patch("db.admin.setup_oauth") as mock_setup_oauth: + patch("db.admin.setup_oauth") as mock_setup_oauth, \ + patch("db.admin.get_engine", return_value=test_db_engine): mock_oauth_client = Mock() mock_oauth_client.authorize_redirect = AsyncMock() mock_oauth_client.authorize_access_token = AsyncMock() diff --git a/tests/db/test_models.py b/tests/db/test_models.py index dfeb2077..20d02080 100644 --- a/tests/db/test_models.py +++ b/tests/db/test_models.py @@ -56,6 +56,7 @@ def test_create_biocommons_user(test_db_session): assert user.id == auth0_id assert user.email == email assert user.username == "user_name" + assert not user.email_verified def test_create_biocommons_user_from_auth0(test_db_session, mock_auth0_client): @@ -71,6 +72,7 @@ def test_create_biocommons_user_from_auth0(test_db_session, mock_auth0_client): assert user.id == user_data.user_id assert user.email == user_data.email assert user.username == user_data.username + assert user.email_verified == user_data.email_verified def test_get_or_create_biocommons_user(test_db_session, mock_auth0_client, persistent_factories): diff --git a/tests/scheduled_tasks/__init__.py b/tests/scheduled_tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/scheduled_tasks/test_tasks.py b/tests/scheduled_tasks/test_tasks.py new file mode 100644 index 00000000..2f4dfcbc --- /dev/null +++ b/tests/scheduled_tasks/test_tasks.py @@ -0,0 +1,50 @@ +from unittest.mock import MagicMock + +import pytest + +from scheduled_tasks.tasks import sync_auth0_users, update_auth0_user +from tests.datagen import Auth0UserDataFactory, UsersWithTotalsFactory +from tests.db.datagen import BiocommonsUserFactory + + +@pytest.mark.asyncio +async def test_sync_auth0_users(mocker, test_client): + """ + Test syncing Auth0 users - users are fetched until the total is reached, + update task is scheduled for each user + """ + mock_auth0_instance = MagicMock() + mocker.patch("scheduled_tasks.tasks.Auth0Client", return_value=mock_auth0_instance) + mocker.patch("scheduled_tasks.tasks.get_settings") + mocker.patch("scheduled_tasks.tasks.get_management_token") + batch1 = UsersWithTotalsFactory.build(total=20, start=0, limit=10) + batch2 = UsersWithTotalsFactory.build(total=20, start=10, limit=10) + mock_scheduler = mocker.patch("scheduled_tasks.tasks.SCHEDULER") + mock_auth0_instance.get_users.side_effect = [batch1, batch2] + await sync_auth0_users() + assert mock_auth0_instance.get_users.call_count == 2 + # Check add_job was called for the number of users + assert mock_scheduler.add_job.call_count == 20 + + +@pytest.mark.asyncio +async def test_update_auth0_user(test_db_session, mocker, persistent_factories): + """ + Test email_verified is updated correctly when updating user from Auth0 + """ + user_data = Auth0UserDataFactory.build( + email_verified=True + ) + db_user = BiocommonsUserFactory.create_sync( + id=user_data.user_id, + email=user_data.email, + username=user_data.username, + email_verified=False + ) + mocker.patch("scheduled_tasks.tasks.get_db_session", + # Needs to be a generator that yields the session + return_value=(test_db_session for _ in range(1))) + await update_auth0_user(user_data=user_data) + test_db_session.flush() + test_db_session.refresh(db_user) + assert db_user.email_verified is True diff --git a/uv.lock b/uv.lock index 3b355dce..1a57c393 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -8,11 +8,13 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "apscheduler", extra = ["redis", "sqlalchemy"] }, { name = "authlib" }, { name = "boto3" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "itsdangerous" }, + { name = "loguru" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, { name = "python-jose" }, @@ -28,6 +30,7 @@ dev = [ { name = "polyfactory" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "respx" }, @@ -37,12 +40,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.16.2" }, + { name = "apscheduler", extras = ["redis", "sqlalchemy"], specifier = "~=3.11" }, { name = "authlib", specifier = ">=1.6.1" }, { name = "boto3", specifier = ">=1.34.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.5.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "loguru", specifier = ">=0.7.3" }, { name = "mimesis", marker = "extra == 'dev'", specifier = "~=18.0" }, { name = "moto", marker = "extra == 'dev'", specifier = ">=5.0.5" }, { name = "polyfactory", marker = "extra == 'dev'", specifier = ">=2.21.0" }, @@ -50,6 +55,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" }, { name = "pydantic-settings", specifier = ">=2.8.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "~=1.2" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" }, { name = "python-jose", specifier = ">=3.4.0" }, @@ -96,6 +102,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "apscheduler" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, +] + +[package.optional-dependencies] +redis = [ + { name = "redis" }, +] +sqlalchemy = [ + { name = "sqlalchemy" }, +] + [[package]] name = "authlib" version = "1.6.1" @@ -537,6 +563,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -827,6 +866,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + [[package]] name = "pytest-cov" version = "6.2.1" @@ -914,6 +965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + [[package]] name = "requests" version = "2.32.4" @@ -1165,6 +1225,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" @@ -1312,6 +1384,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "wtforms" version = "3.1.2"