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"