From 97118a774945754224df96eef41fd1d11656cf6a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?=
<117113383+kemalcalak@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:03:57 +0300
Subject: [PATCH 1/8] feat: Implement user deactivation and reactivation
features with database schema updates
---
...10_drop_token_blacklist_table_moved_to_.py | 44 ++++++++++
...dd_ondelete_cascade_on_user_activity_fk.py | 50 +++++++++++
...1e8fa6b_add_deactivation_fields_to_user.py | 45 ++++++++++
app/core/config.py | 33 ++++++++
app/core/db.py | 11 ++-
app/core/email.py | 83 ++++++++++---------
app/core/rate_limit.py | 14 +++-
app/core/redis.py | 52 ++++++++++++
app/models/__init__.py | 3 +-
app/models/token_blacklist.py | 23 -----
app/models/user.py | 15 +++-
app/models/user_activity.py | 9 +-
12 files changed, 312 insertions(+), 70 deletions(-)
create mode 100644 app/alembic/versions/2fc986d6d710_drop_token_blacklist_table_moved_to_.py
create mode 100644 app/alembic/versions/a48b0bc6e988_add_ondelete_cascade_on_user_activity_fk.py
create mode 100644 app/alembic/versions/db36f1e8fa6b_add_deactivation_fields_to_user.py
create mode 100644 app/core/redis.py
delete mode 100644 app/models/token_blacklist.py
diff --git a/app/alembic/versions/2fc986d6d710_drop_token_blacklist_table_moved_to_.py b/app/alembic/versions/2fc986d6d710_drop_token_blacklist_table_moved_to_.py
new file mode 100644
index 0000000..487a4ee
--- /dev/null
+++ b/app/alembic/versions/2fc986d6d710_drop_token_blacklist_table_moved_to_.py
@@ -0,0 +1,44 @@
+"""drop token_blacklist table (moved to redis)
+
+Revision ID: 2fc986d6d710
+Revises: db36f1e8fa6b
+Create Date: 2026-04-12 07:57:41.442318
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = "2fc986d6d710"
+down_revision: str | Sequence[str] | None = "db36f1e8fa6b"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema: blacklist now lives in Redis."""
+ op.drop_index(op.f("ix_token_blacklist_token"), table_name="token_blacklist")
+ op.drop_table("token_blacklist")
+
+
+def downgrade() -> None:
+ """Downgrade schema: recreate the blacklist table."""
+ op.create_table(
+ "token_blacklist",
+ sa.Column("id", sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column("token", sa.VARCHAR(), autoincrement=False, nullable=False),
+ sa.Column(
+ "created_at",
+ postgresql.TIMESTAMP(timezone=True),
+ autoincrement=False,
+ nullable=False,
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("token_blacklist_pkey")),
+ )
+ op.create_index(
+ op.f("ix_token_blacklist_token"), "token_blacklist", ["token"], unique=True
+ )
diff --git a/app/alembic/versions/a48b0bc6e988_add_ondelete_cascade_on_user_activity_fk.py b/app/alembic/versions/a48b0bc6e988_add_ondelete_cascade_on_user_activity_fk.py
new file mode 100644
index 0000000..2ad3139
--- /dev/null
+++ b/app/alembic/versions/a48b0bc6e988_add_ondelete_cascade_on_user_activity_fk.py
@@ -0,0 +1,50 @@
+"""add ondelete cascade on user_activity fk
+
+Revision ID: a48b0bc6e988
+Revises: 2fc986d6d710
+Create Date: 2026-04-12 08:07:38.336523
+
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "a48b0bc6e988"
+down_revision: str | Sequence[str] | None = "2fc986d6d710"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Swap the user_activity FK for one with ON DELETE CASCADE.
+
+ Lets Postgres fan out the cascade on hard-delete in a single statement
+ instead of the ORM issuing one DELETE per activity row.
+ """
+ op.drop_constraint(
+ "user_activity_user_id_fkey", "user_activity", type_="foreignkey"
+ )
+ op.create_foreign_key(
+ "user_activity_user_id_fkey",
+ "user_activity",
+ "user",
+ ["user_id"],
+ ["id"],
+ ondelete="CASCADE",
+ )
+
+
+def downgrade() -> None:
+ """Revert to a non-cascading FK."""
+ op.drop_constraint(
+ "user_activity_user_id_fkey", "user_activity", type_="foreignkey"
+ )
+ op.create_foreign_key(
+ "user_activity_user_id_fkey",
+ "user_activity",
+ "user",
+ ["user_id"],
+ ["id"],
+ )
diff --git a/app/alembic/versions/db36f1e8fa6b_add_deactivation_fields_to_user.py b/app/alembic/versions/db36f1e8fa6b_add_deactivation_fields_to_user.py
new file mode 100644
index 0000000..f5c9041
--- /dev/null
+++ b/app/alembic/versions/db36f1e8fa6b_add_deactivation_fields_to_user.py
@@ -0,0 +1,45 @@
+"""add deactivation fields to user
+
+Revision ID: db36f1e8fa6b
+Revises: 004c4063ef9a
+Create Date: 2026-04-12 07:55:58.928635
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "db36f1e8fa6b"
+down_revision: str | Sequence[str] | None = "004c4063ef9a"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ op.add_column(
+ "user", sa.Column("deactivated_at", sa.DateTime(timezone=True), nullable=True)
+ )
+ op.add_column(
+ "user",
+ sa.Column("deletion_scheduled_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.create_index(
+ "ix_user_deletion_due",
+ "user",
+ ["deletion_scheduled_at"],
+ unique=False,
+ postgresql_where=sa.text(
+ "is_deleted = false AND deletion_scheduled_at IS NOT NULL"
+ ),
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ op.drop_index("ix_user_deletion_due", table_name="user")
+ op.drop_column("user", "deletion_scheduled_at")
+ op.drop_column("user", "deactivated_at")
diff --git a/app/core/config.py b/app/core/config.py
index f0e49fd..0b389a6 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -1,3 +1,4 @@
+import os
import secrets
import warnings
from typing import Annotated, Literal, Self
@@ -28,6 +29,8 @@ class Settings(BaseSettings):
)
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = secrets.token_urlsafe(32)
+ # 8 days — long enough to avoid surprise logouts, short enough to limit
+ # blast radius of a stolen token when combined with refresh rotation.
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
@@ -60,6 +63,26 @@ def all_cors_origins(self) -> list[str]:
REDIS_URL: str = "redis://localhost:6379/0"
+ # Disposable-email blocklist source. Points to the community-maintained
+ # ``disposable-email-domains`` repo; override for air-gapped deployments.
+ DISPOSABLE_EMAIL_LIST_URL: str = (
+ "https://raw.githubusercontent.com/disposable-email-domains/"
+ "disposable-email-domains/master/disposable_email_blocklist.conf"
+ )
+ DISPOSABLE_EMAIL_CACHE_TTL_SECONDS: int = 60 * 60 * 24
+
+ # Account deactivation + grace-period deletion
+ ACCOUNT_DELETION_GRACE_DAYS: int = 30
+ DELETION_JOB_BATCH_SIZE: int = 100
+ DELETION_JOB_CRON_HOUR: int = 3
+ DELETION_JOB_CRON_MINUTE: int = 0
+
+ # Database connection pool (tuned per API worker)
+ DB_POOL_SIZE: int = 20
+ DB_MAX_OVERFLOW: int = 10
+ DB_POOL_TIMEOUT: int = 30
+ DB_POOL_RECYCLE: int = 1800
+
POSTGRES_SERVER: str
POSTGRES_PORT: int = 5432
POSTGRES_USER: str
@@ -99,6 +122,16 @@ def _enforce_non_default_secrets(self) -> Self:
self._check_default_secret(
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
)
+
+ # SECRET_KEY defaults to a random value at import time. That is fine
+ # for local dev but catastrophic in staging/prod because every restart
+ # invalidates all issued tokens. Require an explicit env value outside
+ # local.
+ if self.ENVIRONMENT != "local" and not os.getenv("SECRET_KEY"):
+ raise ValueError(
+ "SECRET_KEY must be set explicitly via environment for "
+ f"ENVIRONMENT={self.ENVIRONMENT!r}."
+ )
return self
diff --git a/app/core/db.py b/app/core/db.py
index 661ea78..7e7eea8 100644
--- a/app/core/db.py
+++ b/app/core/db.py
@@ -5,8 +5,15 @@
class Base(DeclarativeBase):
- pass
+ """Declarative base for all ORM models."""
-engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
+engine = create_async_engine(
+ str(settings.SQLALCHEMY_DATABASE_URI),
+ pool_size=settings.DB_POOL_SIZE,
+ max_overflow=settings.DB_MAX_OVERFLOW,
+ pool_timeout=settings.DB_POOL_TIMEOUT,
+ pool_recycle=settings.DB_POOL_RECYCLE,
+ pool_pre_ping=True,
+)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
diff --git a/app/core/email.py b/app/core/email.py
index d35e5a6..9c3249d 100644
--- a/app/core/email.py
+++ b/app/core/email.py
@@ -14,76 +14,83 @@
# Cache for disposable domains using Redis
redis_client = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
DISPOSABLE_CACHE_KEY = "disposable_domains_cache"
-DISPOSABLE_CACHE_TTL = 86400 # 24 hours
+DISPOSABLE_CACHE_TTL = settings.DISPOSABLE_EMAIL_CACHE_TTL_SECONDS
_DISPOSABLE_LOCK = asyncio.Lock()
def _get_domain(email: str) -> str | None:
+ """Return the lowercase domain part of ``email`` or ``None`` if malformed."""
parts = email.rsplit("@", 1)
return parts[1].lower() if len(parts) == 2 else None
async def check_mx_record(email: str) -> bool:
+ """Return True when the email domain publishes at least one MX record.
+
+ The DNS lookup is intentionally forgiving — any resolver error is
+ treated as "no valid MX" rather than propagated, so a transient
+ network blip cannot block registration for every caller.
"""
- Check if the domain of the email has valid MX records.
- Returns True if valid, False if not.
- """
- try:
- domain = _get_domain(email)
- if domain is None:
- return False
+ domain = _get_domain(email)
+ if domain is None:
+ return False
- def _resolve_mx() -> int:
- records = dns.resolver.resolve(domain, "MX")
- return len(records)
+ def _resolve_mx() -> int:
+ """Resolve MX records synchronously; runs inside ``asyncio.to_thread``."""
+ records = dns.resolver.resolve(domain, "MX")
+ return len(records)
- # Run the synchronous DNS query in a separate thread
+ try:
count = await asyncio.to_thread(_resolve_mx)
- return count > 0
- except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, Exception) as e:
+ except (
+ dns.resolver.NXDOMAIN,
+ dns.resolver.NoAnswer,
+ dns.resolver.NoNameservers,
+ dns.resolver.LifetimeTimeout,
+ dns.exception.DNSException,
+ ) as e:
logger.warning(f"MX record check failed for {email}: {e}")
return False
+ return count > 0
async def is_disposable_email(email: str) -> bool:
+ """Return True when the email domain matches a known disposable provider.
+
+ Uses a Redis set seeded from an upstream blocklist (lazy fetch on first
+ miss). Any Redis / network error falls open (treated as non-disposable)
+ so an outage cannot lock legitimate users out of registration.
"""
- Check if the email domain belongs to a disposable email provider via Redis cache.
- Returns True if it's disposable, False otherwise.
- """
- try:
- domain = _get_domain(email)
- if domain is None:
- return False
+ domain = _get_domain(email)
+ if domain is None:
+ return False
+ try:
async with _DISPOSABLE_LOCK:
- # Check if domain exists in the Redis set
is_member = await redis_client.sismember(DISPOSABLE_CACHE_KEY, domain)
if not is_member and not await redis_client.exists(DISPOSABLE_CACHE_KEY):
- # The key doesn't exist, we likely need to fetch the list
async with httpx.AsyncClient() as client:
response = await client.get(
- "https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf",
+ settings.DISPOSABLE_EMAIL_LIST_URL,
timeout=5.0,
)
- if response.status_code == 200:
- domains_list = response.text.strip().split("\n")
- if domains_list:
- # Add all domains to the Redis set
- await redis_client.sadd(DISPOSABLE_CACHE_KEY, *domains_list)
- await redis_client.expire(
- DISPOSABLE_CACHE_KEY, DISPOSABLE_CACHE_TTL
- )
- is_member = domain in domains_list
- else:
- logger.warning(
- f"Failed to fetch disposable domains, status code: {response.status_code}"
+ if response.status_code == 200:
+ domains_list = response.text.strip().split("\n")
+ if domains_list:
+ await redis_client.sadd(DISPOSABLE_CACHE_KEY, *domains_list)
+ await redis_client.expire(
+ DISPOSABLE_CACHE_KEY, DISPOSABLE_CACHE_TTL
)
+ is_member = domain in domains_list
+ else:
+ logger.warning(
+ f"Failed to fetch disposable domains, status code: {response.status_code}"
+ )
return bool(is_member)
- except Exception as e:
+ except (aioredis.RedisError, httpx.HTTPError) as e:
logger.error(f"Error checking disposable email for {email}: {e}")
- # If Redis or network error, fail open
return False
diff --git a/app/core/rate_limit.py b/app/core/rate_limit.py
index abd86f6..917ef11 100644
--- a/app/core/rate_limit.py
+++ b/app/core/rate_limit.py
@@ -4,11 +4,23 @@
from app.core.config import settings
+
+def _storage_uri() -> str:
+ """Return the slowapi storage backend.
+
+ Uses in-memory storage for local dev (no Redis required) and async-redis
+ for staging/production so limits are shared across API replicas.
+ """
+ if settings.ENVIRONMENT == "local":
+ return "memory://"
+ return f"async+{settings.REDIS_URL}"
+
+
# Initialize rate limiter
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200/hour", "60/minute"],
- storage_uri="memory://", # Use memory storage (for Redis: redis://localhost:6379)
+ storage_uri=_storage_uri(),
enabled=settings.ENVIRONMENT != "local", # Disable in development
)
diff --git a/app/core/redis.py b/app/core/redis.py
new file mode 100644
index 0000000..c0e0ec0
--- /dev/null
+++ b/app/core/redis.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from redis.asyncio import ConnectionPool, Redis
+
+from app.core.config import settings
+
+_pool: ConnectionPool | None = None
+_client: Redis | None = None
+
+
+def _build_pool() -> ConnectionPool:
+ """Create a new Redis connection pool from settings."""
+ return ConnectionPool.from_url(
+ settings.REDIS_URL,
+ decode_responses=True,
+ max_connections=50,
+ health_check_interval=30,
+ )
+
+
+async def init_redis() -> Redis:
+ """Initialize and return the shared Redis client."""
+ global _pool, _client
+ if _client is None:
+ _pool = _build_pool()
+ _client = Redis(connection_pool=_pool)
+ await _client.ping()
+ return _client
+
+
+async def close_redis() -> None:
+ """Close the shared Redis client and its pool."""
+ global _pool, _client
+ if _client is not None:
+ await _client.aclose()
+ _client = None
+ if _pool is not None:
+ await _pool.aclose()
+ _pool = None
+
+
+def get_redis() -> Redis:
+ """Return the shared Redis client. Raises if not initialized."""
+ if _client is None:
+ raise RuntimeError("Redis client is not initialized")
+ return _client
+
+
+def set_redis_for_testing(client: Redis | None) -> None:
+ """Override the shared Redis client (test-only helper)."""
+ global _client
+ _client = client
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 8ce3f1b..246a378 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -1,5 +1,4 @@
-from app.models.token_blacklist import TokenBlacklist
from app.models.user import User
from app.models.user_activity import UserActivity
-__all__ = ["User", "UserActivity", "TokenBlacklist"]
+__all__ = ["User", "UserActivity"]
diff --git a/app/models/token_blacklist.py b/app/models/token_blacklist.py
deleted file mode 100644
index a5b7ad4..0000000
--- a/app/models/token_blacklist.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import uuid
-from datetime import datetime
-
-from sqlalchemy import DateTime, String
-from sqlalchemy.orm import Mapped, mapped_column
-
-from app.core.db import Base
-from app.utils import utc_now
-
-
-class TokenBlacklist(Base):
- """
- Stores revoked JWT tokens (specifically refresh tokens, or their JTIs).
- Allows preventing usage of tokens after logout or password changes.
- """
-
- __tablename__ = "token_blacklist"
-
- id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
- token: Mapped[str] = mapped_column(String, unique=True, index=True)
- created_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=utc_now
- )
diff --git a/app/models/user.py b/app/models/user.py
index 4d67c48..bb9acd8 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -34,7 +34,20 @@ class User(Base):
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), default=None
)
+ deactivated_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), default=None
+ )
+ # Partial index (defined in migration) avoids indexing rows that will
+ # never match the deletion query — see ix_user_deletion_due.
+ deletion_scheduled_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), default=None
+ )
+ # passive_deletes=True lets Postgres handle the cascade via the FK's
+ # ON DELETE CASCADE — a single DELETE statement instead of one per row.
activities: Mapped[list["UserActivity"]] = relationship(
- "UserActivity", back_populates="user"
+ "UserActivity",
+ back_populates="user",
+ cascade="all, delete-orphan",
+ passive_deletes=True,
)
diff --git a/app/models/user_activity.py b/app/models/user_activity.py
index 27c3ea7..c12b6ad 100644
--- a/app/models/user_activity.py
+++ b/app/models/user_activity.py
@@ -1,6 +1,6 @@
import uuid
from datetime import datetime
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import JSON
@@ -11,6 +11,7 @@
from app.models.user import User
from app.core.db import Base
+from app.schemas.common import ActivityDetails
from app.schemas.user_activity import ActivityStatus
from app.utils import utc_now
@@ -26,7 +27,9 @@ class UserActivity(Base):
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
- ForeignKey("user.id"), index=True, nullable=False
+ ForeignKey("user.id", ondelete="CASCADE"),
+ index=True,
+ nullable=False,
)
# Enums stored as strings
@@ -37,7 +40,7 @@ class UserActivity(Base):
PG_UUID(as_uuid=True), default=None, index=True
)
- details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
+ details: Mapped[ActivityDetails] = mapped_column(JSON, default=dict, nullable=False)
status: Mapped[str] = mapped_column(
String, default=ActivityStatus.SUCCESS.value, nullable=False
From 428250191cd087f5872e33a1feb924e658329619 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?=
<117113383+kemalcalak@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:04:13 +0300
Subject: [PATCH 2/8] feat: Add common schemas for user activity and background
worker jobs
---
app/schemas/common.py | 12 ++++++++++++
app/schemas/user.py | 2 ++
app/schemas/user_activity.py | 7 ++++---
app/schemas/worker.py | 13 +++++++++++++
4 files changed, 31 insertions(+), 3 deletions(-)
create mode 100644 app/schemas/common.py
create mode 100644 app/schemas/worker.py
diff --git a/app/schemas/common.py b/app/schemas/common.py
new file mode 100644
index 0000000..b3b903e
--- /dev/null
+++ b/app/schemas/common.py
@@ -0,0 +1,12 @@
+"""Shared schema primitives used across multiple domains."""
+
+from __future__ import annotations
+
+from pydantic import JsonValue
+
+__all__ = ["JsonValue", "ActivityDetails"]
+
+# Structured audit-log payload — a JSON object with typed leaf values.
+# Any value representable in JSON is accepted, so callers can pass plain
+# dicts (``{"reason": "invalid_password"}``) without ceremony.
+ActivityDetails = dict[str, JsonValue]
diff --git a/app/schemas/user.py b/app/schemas/user.py
index cf9ddb9..82fdf57 100644
--- a/app/schemas/user.py
+++ b/app/schemas/user.py
@@ -88,6 +88,8 @@ class UserPublic(UserBase):
role: SystemRole
created_at: datetime
updated_at: datetime
+ deactivated_at: datetime | None = None
+ deletion_scheduled_at: datetime | None = None
class UserUpdateResponse(BaseModel):
diff --git a/app/schemas/user_activity.py b/app/schemas/user_activity.py
index 7535121..3439251 100644
--- a/app/schemas/user_activity.py
+++ b/app/schemas/user_activity.py
@@ -1,8 +1,9 @@
import uuid
from enum import StrEnum
-from typing import Any
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
+
+from app.schemas.common import ActivityDetails
class ActivityType(StrEnum):
@@ -40,7 +41,7 @@ class UserActivityCreate(BaseModel):
activity_type: ActivityType
resource_type: ResourceType
resource_id: uuid.UUID | None = None
- details: dict[str, Any] = {}
+ details: ActivityDetails = Field(default_factory=dict)
status: ActivityStatus = ActivityStatus.SUCCESS
ip_address: str | None = None
user_agent: str | None = None
diff --git a/app/schemas/worker.py b/app/schemas/worker.py
new file mode 100644
index 0000000..c27e7e0
--- /dev/null
+++ b/app/schemas/worker.py
@@ -0,0 +1,13 @@
+"""Schemas used by background-worker jobs."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class DeletionJobResult(BaseModel):
+ """Outcome of a single run of the expired-account deletion job."""
+
+ processed: int = Field(ge=0, description="Users hard-deleted this run.")
+ failed: int = Field(ge=0, description="Users that errored and will be retried.")
+ duration_ms: int = Field(ge=0, description="Total wall-clock time in ms.")
From 1d79156ffa2e7e9903e33645e9c00cb83f054da3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?=
<117113383+kemalcalak@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:04:24 +0300
Subject: [PATCH 3/8] feat: Implement user deactivation and reactivation logic
with grace period handling
---
app/repositories/token_blacklist.py | 87 ++++++++++++++++++++++++-----
app/repositories/user.py | 87 ++++++++++++++++++++++++++++-
2 files changed, 157 insertions(+), 17 deletions(-)
diff --git a/app/repositories/token_blacklist.py b/app/repositories/token_blacklist.py
index 54918d3..08b449f 100644
--- a/app/repositories/token_blacklist.py
+++ b/app/repositories/token_blacklist.py
@@ -1,20 +1,77 @@
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
+"""Redis-backed JWT blacklist.
-from app.models.token_blacklist import TokenBlacklist
+Revoked tokens are stored under ``blacklist:jwt:{jti}`` with a TTL equal to
+the token's remaining lifetime — Redis evicts them automatically when they
+expire, so no cleanup job is required. Lookups are O(1) and safe to run
+across any number of API replicas or worker processes.
+"""
+from __future__ import annotations
-async def add_token_to_blacklist(session: AsyncSession, token: str) -> TokenBlacklist:
- """Add a token to the blacklist."""
- blacklisted_token = TokenBlacklist(token=token)
- session.add(blacklisted_token)
- await session.commit()
- await session.refresh(blacklisted_token)
- return blacklisted_token
+import logging
+from datetime import UTC, datetime
+import jwt
-async def is_token_blacklisted(session: AsyncSession, token: str) -> bool:
- """Check if a token exists in the blacklist."""
- statement = select(TokenBlacklist).where(TokenBlacklist.token == token)
- result = await session.execute(statement)
- return result.scalars().first() is not None
+from app.core.config import settings
+from app.core.redis import get_redis
+from app.utils import utc_now
+
+logger = logging.getLogger(__name__)
+
+_KEY_PREFIX = "blacklist:jwt:"
+# Fallback TTL when a token's expiry cannot be parsed (should not happen
+# with valid JWTs but we never want a revoked token to live forever).
+_FALLBACK_TTL_SECONDS = 60 * 60 * 24 * settings.REFRESH_TOKEN_EXPIRE_DAYS
+
+
+def _key(jti: str) -> str:
+ """Build the Redis key for a given JWT id."""
+ return f"{_KEY_PREFIX}{jti}"
+
+
+def _extract_claims(token: str) -> tuple[str, int]:
+ """Return (jti, remaining_ttl_seconds) for a token without verifying it.
+
+ Signature verification is intentionally skipped — callers must have
+ already validated the token (or are deliberately revoking an expired
+ one). We only need the claims to choose a stable key and TTL.
+ """
+ try:
+ payload = jwt.decode(
+ token,
+ options={"verify_signature": False, "verify_exp": False},
+ )
+ except jwt.PyJWTError:
+ return token, _FALLBACK_TTL_SECONDS
+
+ jti = str(payload.get("jti") or token)
+ exp = payload.get("exp")
+ if exp is None:
+ return jti, _FALLBACK_TTL_SECONDS
+
+ try:
+ expires_at = datetime.fromtimestamp(float(exp), tz=UTC)
+ except (TypeError, ValueError, OverflowError):
+ return jti, _FALLBACK_TTL_SECONDS
+
+ remaining = int((expires_at - utc_now()).total_seconds())
+ if remaining <= 0:
+ # Token already expired; still blacklist briefly to stop reuse in a
+ # narrow clock-skew window but let Redis evict it quickly.
+ return jti, 60
+ return jti, remaining
+
+
+async def add_token_to_blacklist(token: str) -> None:
+ """Revoke a JWT by storing its jti in Redis until the token expires."""
+ jti, ttl = _extract_claims(token)
+ redis = get_redis()
+ await redis.set(_key(jti), "1", ex=ttl)
+
+
+async def is_token_blacklisted(token: str) -> bool:
+ """Return True if the given JWT has been revoked."""
+ jti, _ = _extract_claims(token)
+ redis = get_redis()
+ return bool(await redis.exists(_key(jti)))
diff --git a/app/repositories/user.py b/app/repositories/user.py
index bfcf15d..a17d41d 100644
--- a/app/repositories/user.py
+++ b/app/repositories/user.py
@@ -1,7 +1,8 @@
import uuid
from collections.abc import Sequence
+from datetime import datetime, timedelta
-from sqlalchemy import func, select
+from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
@@ -70,7 +71,89 @@ async def soft_delete_user(session: AsyncSession, user: User) -> None:
await session.commit()
+async def deactivate_user(session: AsyncSession, user: User, grace_days: int) -> User:
+ """Start the deactivation grace window for a user.
+
+ Sets ``is_active=False`` and schedules hard deletion ``grace_days`` in the
+ future. The row is locked with ``SELECT ... FOR UPDATE`` to guard against
+ concurrent deactivate/reactivate requests on the same account.
+ """
+ locked = await session.execute(
+ select(User).where(User.id == user.id).with_for_update()
+ )
+ db_user = locked.scalars().one()
+
+ now = utc_now()
+ db_user.is_active = False
+ db_user.deactivated_at = now
+ db_user.deletion_scheduled_at = now + timedelta(days=grace_days)
+ session.add(db_user)
+ await session.commit()
+ await session.refresh(db_user)
+ return db_user
+
+
+async def reactivate_user(session: AsyncSession, user: User) -> User:
+ """Cancel a pending deletion and re-enable the account."""
+ locked = await session.execute(
+ select(User).where(User.id == user.id).with_for_update()
+ )
+ db_user = locked.scalars().one()
+
+ db_user.is_active = True
+ db_user.deactivated_at = None
+ db_user.deletion_scheduled_at = None
+ session.add(db_user)
+ await session.commit()
+ await session.refresh(db_user)
+ return db_user
+
+
+async def get_users_due_for_deletion(
+ session: AsyncSession, now: datetime, limit: int
+) -> Sequence[User]:
+ """Return users whose grace period has elapsed, locked for this worker.
+
+ Uses ``FOR UPDATE SKIP LOCKED`` so multiple workers can run the deletion
+ job in parallel without colliding on the same rows.
+ """
+ statement = (
+ select(User)
+ .where(
+ User.is_deleted.is_(False),
+ User.deletion_scheduled_at.is_not(None),
+ User.deletion_scheduled_at <= now,
+ )
+ .order_by(User.deletion_scheduled_at)
+ .with_for_update(skip_locked=True)
+ .limit(limit)
+ )
+ result = await session.execute(statement)
+ return result.scalars().all()
+
+
+async def hard_delete_user(session: AsyncSession, user: User) -> None:
+ """Permanently remove a user. Child rows cascade at the DB level."""
+ await session.delete(user)
+ await session.commit()
+
+
+async def bulk_hard_delete_users(
+ session: AsyncSession, user_ids: Sequence[uuid.UUID]
+) -> int:
+ """Delete many users in a single SQL statement.
+
+ Relies on the ``ON DELETE CASCADE`` foreign key from ``user_activity``
+ so no ORM-level fan-out is required. Returns the number of rows removed.
+ The caller owns the transaction boundary.
+ """
+ if not user_ids:
+ return 0
+ result = await session.execute(delete(User).where(User.id.in_(list(user_ids))))
+ return result.rowcount or 0
+
+
async def delete_user(session: AsyncSession, db_user: User) -> None:
- """Delete a user from the database."""
+ """Hard-delete a user and commit. Caller owns the detach before calling."""
await session.delete(db_user)
await session.commit()
From 6aff7b74a79a94b56e6457e5164d28902ba2be7a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?=
<117113383+kemalcalak@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:04:48 +0300
Subject: [PATCH 4/8] feat: Add user account deactivation and reactivation
features with grace period handling
---
app/api/decorators.py | 84 ++++++
app/api/deps.py | 31 +-
app/api/routes/auth.py | 389 ++++++++++----------------
app/api/routes/users.py | 149 +++++-----
app/core/messages/error_message.py | 7 +
app/core/messages/success_message.py | 4 +
app/services/auth_service.py | 64 ++---
app/services/user_activity_service.py | 4 +-
app/services/user_service.py | 83 +++++-
9 files changed, 432 insertions(+), 383 deletions(-)
create mode 100644 app/api/decorators.py
diff --git a/app/api/decorators.py b/app/api/decorators.py
new file mode 100644
index 0000000..b76b997
--- /dev/null
+++ b/app/api/decorators.py
@@ -0,0 +1,84 @@
+"""Cross-cutting route decorators.
+
+Kept separate from ``api/deps.py`` because FastAPI ``Depends`` belong there
+and mixing the two makes it harder to spot what is pure wrapper vs. DI.
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import Awaitable, Callable, Mapping
+from functools import wraps
+from typing import ParamSpec, TypeVar
+
+from fastapi import HTTPException, Request
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.user import User
+from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType
+from app.services.user_activity_service import log_activity
+
+_UNKNOWN_USER_ID = uuid.UUID(int=0)
+"""Placeholder used when an unexpected failure fires before the caller is known.
+
+Kept explicit so audit-log readers can recognise the sentinel.
+"""
+
+P = ParamSpec("P")
+R = TypeVar("R")
+
+
+def audit_unexpected_failure(
+ *,
+ activity_type: ActivityType,
+ resource_type: ResourceType,
+ endpoint: str,
+) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
+ """Log unexpected failures of a route handler and re-raise the original error.
+
+ ``HTTPException`` is passed through unchanged so FastAPI's own response
+ logic still runs. Every other exception is recorded against the caller
+ (or ``_UNKNOWN_USER_ID`` when we cannot yet identify them) and re-raised
+ so the global exception handler can convert it to a 500 with the full
+ traceback intact.
+ """
+
+ def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
+ @wraps(func)
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+ try:
+ return await func(*args, **kwargs)
+ except HTTPException:
+ raise
+ except Exception as exc:
+ session = _find_kwarg(kwargs, AsyncSession)
+ request = _find_kwarg(kwargs, Request)
+ current_user = _find_kwarg(kwargs, User)
+ if session is not None:
+ await log_activity(
+ session=session,
+ user_id=current_user.id if current_user else _UNKNOWN_USER_ID,
+ activity_type=activity_type,
+ resource_type=resource_type,
+ status=ActivityStatus.FAILURE,
+ details={"error": str(exc), "endpoint": endpoint},
+ request=request,
+ )
+ raise
+
+ return wrapper
+
+ return decorator
+
+
+def _find_kwarg[T](kwargs: Mapping[str, object], expected_type: type[T]) -> T | None:
+ """Return the first kwarg whose runtime type matches ``expected_type``.
+
+ Route signatures vary (``session``/``db``, ``current_user`` may be
+ ``CurrentUser`` or ``CurrentActiveUser``), so look up by type instead of
+ name to keep the decorator generic.
+ """
+ for value in kwargs.values():
+ if isinstance(value, expected_type):
+ return value
+ return None
diff --git a/app/api/deps.py b/app/api/deps.py
index 57194a0..92d6cf7 100644
--- a/app/api/deps.py
+++ b/app/api/deps.py
@@ -33,9 +33,11 @@ async def get_current_user(
db: Annotated[AsyncSession, Depends(get_db)],
bearer_token: Annotated[str | None, Depends(reusable_oauth2)] = None,
) -> User:
- """
- Get current authenticated user from JWT token.
- Cookie takes priority; falls back to Authorization Bearer header.
+ """Resolve the JWT to a User, allowing accounts in the deletion grace window.
+
+ This intentionally does NOT reject ``is_active=False`` users — that check
+ moved to ``get_current_active_user`` so deactivated users can still hit
+ ``/users/me`` and ``/users/me/reactivate`` during the grace period.
"""
token = request.cookies.get("access_token") or bearer_token
if not token:
@@ -46,15 +48,13 @@ async def get_current_user(
)
try:
- # Check if token is blacklisted
- if await is_token_blacklisted(db, token):
+ if await is_token_blacklisted(token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ErrorMessages.INVALID_TOKEN,
headers={"WWW-Authenticate": "Bearer"},
)
- # Verify and decode token
token_subject = verify_token(token)
if token_subject is None:
raise HTTPException(
@@ -63,7 +63,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
- # Validate token payload
token_data = TokenPayload(sub=token_subject)
except (ValidationError, ValueError):
raise HTTPException(
@@ -72,33 +71,31 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
- # Get user from database
user = await get_user_by_id(db, user_id=uuid.UUID(token_data.sub))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ErrorMessages.USER_NOT_FOUND
)
- # Check if user is active
- if not user.is_active:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorMessages.USER_INACTIVE
- )
-
return user
def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
- """Get current active user."""
+ """Require the caller's account to be active (not in deletion grace)."""
+ if not current_user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ErrorMessages.USER_INACTIVE,
+ )
return current_user
def get_current_superuser(
- current_user: Annotated[User, Depends(get_current_user)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
) -> User:
- """Get current user if they are a system admin."""
+ """Require the caller to be an active system admin."""
if current_user.role != SystemRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py
index b9876cc..b821557 100644
--- a/app/api/routes/auth.py
+++ b/app/api/routes/auth.py
@@ -1,13 +1,14 @@
-import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
-from app.api.deps import CurrentUser, SessionDep
+from app.api.decorators import audit_unexpected_failure
+from app.api.deps import CurrentActiveUser, SessionDep
from app.core.config import settings
from app.core.messages.error_message import ErrorMessages
from app.core.messages.success_message import SuccessMessages
+from app.core.rate_limit import rate_limit_public, rate_limit_strict
from app.schemas.msg import Message
from app.schemas.token import (
CookieLoginResponse,
@@ -20,7 +21,7 @@
UserCreate,
VerifyEmail,
)
-from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType
+from app.schemas.user_activity import ActivityType, ResourceType
from app.services.auth_service import (
change_password_service,
login_service,
@@ -32,83 +33,90 @@
reset_password_service,
verify_email_service,
)
-from app.services.user_activity_service import log_activity
router = APIRouter()
+# The refresh cookie is limited to the refresh endpoint only so it is never
+# sent on unrelated requests. This path must match for both set_cookie and
+# delete_cookie calls or the cookie cannot be cleared.
+_REFRESH_COOKIE_PATH = f"{settings.API_V1_STR}/auth/refresh"
+_COOKIE_SECURE = settings.ENVIRONMENT != "local"
+
+
+def _set_access_cookie(response: Response, token: str) -> None:
+ """Write the access token as an HttpOnly cookie for the whole API."""
+ response.set_cookie(
+ key="access_token",
+ value=token,
+ httponly=True,
+ secure=_COOKIE_SECURE,
+ samesite="lax",
+ max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
+ path="/",
+ )
+
+
+def _set_refresh_cookie(response: Response, token: str) -> None:
+ """Write the refresh token as an HttpOnly, path-scoped cookie."""
+ response.set_cookie(
+ key="refresh_token",
+ value=token,
+ httponly=True,
+ secure=_COOKIE_SECURE,
+ samesite="strict",
+ max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
+ path=_REFRESH_COOKIE_PATH,
+ )
+
+
+def _clear_auth_cookies(response: Response) -> None:
+ """Remove both auth cookies using the same paths they were set with."""
+ response.delete_cookie(key="access_token", path="/")
+ response.delete_cookie(key="refresh_token", path=_REFRESH_COOKIE_PATH)
+
@router.post(
"/login", response_model=CookieLoginResponse, status_code=status.HTTP_200_OK
)
+@rate_limit_strict("5/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.LOGIN,
+ resource_type=ResourceType.AUTH,
+ endpoint="/login",
+)
async def login_access_token(
response: Response,
request: Request,
session: SessionDep,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> CookieLoginResponse:
- """
- OAuth2 compatible token login, get an access token for future requests.
- """
- try:
- result = await login_service(
- request=request,
- session=session,
- email=form_data.username,
- password=form_data.password,
- )
-
- # Set access token in HttpOnly cookie
- response.set_cookie(
- key="access_token",
- value=result.access_token,
- httponly=True,
- secure=settings.ENVIRONMENT != "local",
- samesite="lax",
- max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
- path="/",
- )
-
- # Set refresh token in HttpOnly cookie
- response.set_cookie(
- key="refresh_token",
- value=result.refresh_token,
- httponly=True,
- secure=settings.ENVIRONMENT != "local",
- samesite="lax",
- max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
- path=f"{settings.API_V1_STR}/auth/refresh",
- )
-
- return CookieLoginResponse(
- user=result.user,
- message=result.message,
- )
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=uuid.UUID(int=0),
- activity_type=ActivityType.LOGIN,
- resource_type=ResourceType.AUTH,
- status=ActivityStatus.FAILURE,
- details={"error": str(e), "endpoint": "/login"},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ """OAuth2 compatible token login, get an access token for future requests."""
+ result = await login_service(
+ request=request,
+ session=session,
+ email=form_data.username,
+ password=form_data.password,
+ )
+ _set_access_cookie(response, result.access_token)
+ _set_refresh_cookie(response, result.refresh_token)
+ return CookieLoginResponse(user=result.user, message=result.message)
@router.post(
"/refresh", response_model=CookieRefreshResponse, status_code=status.HTTP_200_OK
)
+@rate_limit_public("30/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.LOGIN,
+ resource_type=ResourceType.AUTH,
+ endpoint="/refresh",
+)
async def refresh_token(
request: Request,
response: Response,
session: SessionDep,
) -> CookieRefreshResponse:
- """
- Refresh access token using the refresh token from cookie.
- """
+ """Refresh access token using the refresh token from cookie."""
refresh_token_cookie = request.cookies.get("refresh_token")
if not refresh_token_cookie:
raise HTTPException(
@@ -116,234 +124,133 @@ async def refresh_token(
detail=ErrorMessages.REFRESH_TOKEN_MISSING,
)
- try:
- result = await refresh_token_service(
- request=request, session=session, refresh_token=refresh_token_cookie
- )
-
- # Set new access token in HttpOnly cookie
- response.set_cookie(
- key="access_token",
- value=result.access_token,
- httponly=True,
- secure=settings.ENVIRONMENT != "local",
- samesite="lax",
- max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
- path="/",
- )
-
- return CookieRefreshResponse(message=result.message)
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=uuid.UUID(int=0), # Unknown user
- activity_type=ActivityType.LOGIN,
- resource_type=ResourceType.AUTH,
- status=ActivityStatus.FAILURE,
- details={"error": str(e), "endpoint": "/refresh"},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ result = await refresh_token_service(
+ request=request, session=session, refresh_token=refresh_token_cookie
+ )
+ _set_access_cookie(response, result.access_token)
+ return CookieRefreshResponse(message=result.message)
@router.post("/logout", response_model=Message, status_code=status.HTTP_200_OK)
+@audit_unexpected_failure(
+ activity_type=ActivityType.LOGOUT,
+ resource_type=ResourceType.AUTH,
+ endpoint="/logout",
+)
async def logout(request: Request, response: Response, session: SessionDep) -> Message:
- """
- Clear refresh token cookie and invalidate token in the blacklist.
- """
- refresh_token = request.cookies.get("refresh_token")
- try:
- if refresh_token:
- await logout_service(
- request=request, session=session, refresh_token=refresh_token
- )
-
- response.delete_cookie(key="access_token", path="/")
- response.delete_cookie(
- key="refresh_token",
- path=f"{settings.API_V1_STR}/auth/refresh",
- )
- return Message(success=True, message=SuccessMessages.LOGOUT_SUCCESS)
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=uuid.UUID(int=0), # Unknown user
- activity_type=ActivityType.LOGOUT,
- resource_type=ResourceType.AUTH,
- status=ActivityStatus.FAILURE,
- details={"error": str(e), "endpoint": "/logout"},
- request=request,
+ """Clear refresh token cookie and invalidate token in the blacklist."""
+ refresh_token_cookie = request.cookies.get("refresh_token")
+ if refresh_token_cookie:
+ await logout_service(
+ request=request, session=session, refresh_token=refresh_token_cookie
)
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ _clear_auth_cookies(response)
+ return Message(success=True, message=SuccessMessages.LOGOUT_SUCCESS)
@router.post("/register", response_model=Message, status_code=status.HTTP_201_CREATED)
+@rate_limit_strict("5/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.CREATE,
+ resource_type=ResourceType.USER,
+ endpoint="/register",
+)
async def register_user(
request: Request, session: SessionDep, user_in: UserCreate
) -> Message:
- """
- Register a new user.
- """
- try:
- await register_service(request=request, session=session, user_create=user_in)
- return Message(success=True, message=SuccessMessages.REGISTER_SUCCESS)
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=uuid.UUID(int=0),
- activity_type=ActivityType.CREATE,
- resource_type=ResourceType.USER,
- status=ActivityStatus.FAILURE,
- details={"error": str(e), "endpoint": "/register"},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ """Register a new user."""
+ await register_service(request=request, session=session, user_create=user_in)
+ return Message(success=True, message=SuccessMessages.REGISTER_SUCCESS)
@router.post("/verify-email", response_model=Message, status_code=status.HTTP_200_OK)
+@rate_limit_public("10/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.UPDATE,
+ resource_type=ResourceType.USER,
+ endpoint="/verify-email",
+)
async def verify_email(
request: Request, session: SessionDep, body: VerifyEmail
) -> Message:
- """
- Verify user email using the token sent via email.
- """
- try:
- return await verify_email_service(
- request=request, session=session, token=body.token
- )
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=uuid.UUID(int=0),
- activity_type=ActivityType.UPDATE,
- resource_type=ResourceType.USER,
- status=ActivityStatus.FAILURE,
- details={"error": str(e), "endpoint": "/verify-email"},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ """Verify user email using the token sent via email."""
+ return await verify_email_service(
+ request=request, session=session, token=body.token
+ )
@router.post("/forgot-password", response_model=Message, status_code=status.HTTP_200_OK)
+@rate_limit_strict("3/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.UPDATE,
+ resource_type=ResourceType.USER,
+ endpoint="/forgot-password",
+)
async def forgot_password(
request: Request, session: SessionDep, body: ForgotPassword
) -> Message:
- """
- Send an email with a password reset link.
- """
- try:
- return await recover_password_service(
- request=request, session=session, email=body.email, lang=body.lang
- )
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=uuid.UUID(int=0),
- activity_type=ActivityType.UPDATE,
- resource_type=ResourceType.USER,
- status=ActivityStatus.FAILURE,
- details={"error": str(e), "endpoint": "/forgot-password"},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ """Send an email with a password reset link."""
+ return await recover_password_service(
+ request=request, session=session, email=body.email, lang=body.lang
+ )
@router.post("/reset-password", response_model=Message, status_code=status.HTTP_200_OK)
+@rate_limit_strict("5/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.UPDATE,
+ resource_type=ResourceType.USER,
+ endpoint="/reset-password",
+)
async def reset_password(
request: Request, session: SessionDep, body: NewPassword
) -> Message:
- """
- Reset password using a token.
- """
- try:
- return await reset_password_service(
- request=request,
- session=session,
- token=body.token,
- new_password=body.new_password,
- )
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=uuid.UUID(int=0),
- activity_type=ActivityType.UPDATE,
- resource_type=ResourceType.USER,
- status=ActivityStatus.FAILURE,
- details={"error": str(e), "endpoint": "/reset-password"},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ """Reset password using a token."""
+ return await reset_password_service(
+ request=request,
+ session=session,
+ token=body.token,
+ new_password=body.new_password,
+ )
@router.post(
"/resend-verification", response_model=Message, status_code=status.HTTP_200_OK
)
+@rate_limit_strict("3/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.UPDATE,
+ resource_type=ResourceType.USER,
+ endpoint="/resend-verification",
+)
async def resend_verification(
request: Request, session: SessionDep, body: ForgotPassword
) -> Message:
- """
- Resend verification email.
- """
- try:
- return await resend_verification_service(
- request=request, session=session, email=body.email, lang=body.lang
- )
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=uuid.UUID(int=0),
- activity_type=ActivityType.UPDATE,
- resource_type=ResourceType.USER,
- status=ActivityStatus.FAILURE,
- details={"error": str(e), "endpoint": "/resend-verification"},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ """Resend verification email."""
+ return await resend_verification_service(
+ request=request, session=session, email=body.email, lang=body.lang
+ )
@router.patch(
"/change-password", response_model=Message, status_code=status.HTTP_200_OK
)
+@rate_limit_strict("5/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.UPDATE,
+ resource_type=ResourceType.AUTH,
+ endpoint="/change-password",
+)
async def change_password(
request: Request,
session: SessionDep,
- current_user: CurrentUser,
+ current_user: CurrentActiveUser,
body: UpdatePassword,
) -> Message:
- """
- Change user password while logged in.
- """
- try:
- return await change_password_service(
- request=request,
- session=session,
- current_user=current_user,
- update_password=body,
- )
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=current_user.id,
- activity_type=ActivityType.UPDATE,
- resource_type=ResourceType.AUTH,
- status=ActivityStatus.FAILURE,
- details={"error": str(e), "endpoint": "/change-password"},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ """Change user password while logged in."""
+ return await change_password_service(
+ request=request,
+ session=session,
+ current_user=current_user,
+ update_password=body,
+ )
diff --git a/app/api/routes/users.py b/app/api/routes/users.py
index 3303219..406a018 100644
--- a/app/api/routes/users.py
+++ b/app/api/routes/users.py
@@ -1,8 +1,10 @@
-from fastapi import APIRouter, HTTPException, Request
+from fastapi import APIRouter, Request, Response
-from app.api.deps import CurrentUser, SessionDep
-from app.core.messages.error_message import ErrorMessages
+from app.api.decorators import audit_unexpected_failure
+from app.api.deps import CurrentActiveUser, CurrentUser, SessionDep
+from app.core.config import settings
from app.core.messages.success_message import SuccessMessages
+from app.core.rate_limit import rate_limit_strict
from app.schemas.msg import Message
from app.schemas.user import (
DeleteAccount,
@@ -10,101 +12,92 @@
UserUpdateMe,
UserUpdateResponse,
)
-from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType
-from app.services.user_activity_service import log_activity
-from app.services.user_service import delete_own_account_service, update_user_service
+from app.schemas.user_activity import ActivityType, ResourceType
+from app.services.user_service import (
+ deactivate_own_account_service,
+ reactivate_own_account_service,
+ update_user_service,
+)
router = APIRouter()
@router.get("/me", response_model=UserPublic)
-async def read_user_me(
- request: Request, session: SessionDep, current_user: CurrentUser
-) -> UserPublic:
- """
- Get current user.
- """
- try:
- return current_user
- except Exception as e:
- await log_activity(
- session=session,
- user_id=current_user.id,
- activity_type=ActivityType.READ,
- resource_type=ResourceType.USER,
- resource_id=current_user.id,
- status=ActivityStatus.FAILURE,
- details={"error": str(e)},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+async def read_user_me(current_user: CurrentUser) -> UserPublic:
+ """Get current user."""
+ return current_user
@router.patch("/me", response_model=UserUpdateResponse)
+@audit_unexpected_failure(
+ activity_type=ActivityType.UPDATE,
+ resource_type=ResourceType.USER,
+ endpoint="/users/me",
+)
async def update_user_me(
request: Request,
session: SessionDep,
- current_user: CurrentUser,
+ current_user: CurrentActiveUser,
user_in: UserUpdateMe,
) -> UserUpdateResponse:
- """
- Update own user details.
- """
- try:
- updated_user = await update_user_service(
- request=request,
- session=session,
- current_user=current_user,
- user_id=current_user.id,
- user_update=user_in,
- )
- return UserUpdateResponse(
- user=updated_user, message=SuccessMessages.USER_UPDATED
- )
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=current_user.id,
- activity_type=ActivityType.UPDATE,
- resource_type=ResourceType.USER,
- resource_id=current_user.id,
- status=ActivityStatus.FAILURE,
- details={"error": str(e)},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ """Update own user details."""
+ updated_user = await update_user_service(
+ request=request,
+ session=session,
+ current_user=current_user,
+ user_id=current_user.id,
+ user_update=user_in,
+ )
+ return UserUpdateResponse(user=updated_user, message=SuccessMessages.USER_UPDATED)
@router.delete("/me", response_model=Message)
+@rate_limit_strict("3/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.UPDATE,
+ resource_type=ResourceType.USER,
+ endpoint="/users/me (deactivate)",
+)
async def delete_user_me(
request: Request,
+ response: Response,
session: SessionDep,
current_user: CurrentUser,
body: DeleteAccount,
) -> Message:
+ """Deactivate own account and schedule hard deletion after grace days.
+
+ The account is not removed immediately — ``ACCOUNT_DELETION_GRACE_DAYS``
+ later, the arq worker performs the irreversible delete. The user may
+ cancel the deletion via ``POST /users/me/reactivate`` before that.
+ The caller's auth cookies are cleared and their tokens revoked.
"""
- Delete own user profile with password confirmation.
- """
- try:
- return await delete_own_account_service(
- request=request,
- session=session,
- current_user=current_user,
- password=body.password,
- )
- except HTTPException:
- raise
- except Exception as e:
- await log_activity(
- session=session,
- user_id=current_user.id,
- activity_type=ActivityType.DELETE,
- resource_type=ResourceType.USER,
- resource_id=current_user.id,
- status=ActivityStatus.FAILURE,
- details={"error": str(e)},
- request=request,
- )
- raise HTTPException(status_code=500, detail=ErrorMessages.INTERNAL_SERVER_ERROR)
+ result = await deactivate_own_account_service(
+ request=request,
+ session=session,
+ current_user=current_user,
+ password=body.password,
+ )
+ response.delete_cookie(key="access_token", path="/")
+ response.delete_cookie(
+ key="refresh_token", path=f"{settings.API_V1_STR}/auth/refresh"
+ )
+ return result
+
+
+@router.post("/me/reactivate", response_model=Message)
+@rate_limit_strict("5/minute")
+@audit_unexpected_failure(
+ activity_type=ActivityType.UPDATE,
+ resource_type=ResourceType.USER,
+ endpoint="/users/me/reactivate",
+)
+async def reactivate_user_me(
+ request: Request,
+ session: SessionDep,
+ current_user: CurrentUser,
+) -> Message:
+ """Cancel a pending account deletion while still inside the grace window."""
+ return await reactivate_own_account_service(
+ request=request, session=session, current_user=current_user
+ )
diff --git a/app/core/messages/error_message.py b/app/core/messages/error_message.py
index 8a7529e..ca2d3a4 100644
--- a/app/core/messages/error_message.py
+++ b/app/core/messages/error_message.py
@@ -19,3 +19,10 @@ class ErrorMessages:
INVALID_VERIFICATION_TOKEN = "error.user.invalid_verification_token"
REFRESH_TOKEN_MISSING = "error.auth.refresh_token_missing"
INVALID_CURRENT_PASSWORD = "error.auth.invalid_current_password"
+ INVALID_EMAIL_DOMAIN = "error.user.invalid_email_domain"
+ DISPOSABLE_EMAIL_NOT_ALLOWED = "error.user.disposable_email_not_allowed"
+
+ # Account deactivation / grace-period deletion
+ ACCOUNT_ALREADY_DEACTIVATED = "error.account.already_deactivated"
+ ACCOUNT_NOT_DEACTIVATED = "error.account.not_deactivated"
+ ACCOUNT_DELETION_EXPIRED = "error.account.deletion_expired"
diff --git a/app/core/messages/success_message.py b/app/core/messages/success_message.py
index cc99125..ae29c57 100644
--- a/app/core/messages/success_message.py
+++ b/app/core/messages/success_message.py
@@ -13,3 +13,7 @@ class SuccessMessages:
PASSWORD_CHANGE_SUCCESS = "success.auth.password_change_success"
LOGOUT_SUCCESS = "success.auth.logout_success"
REGISTER_SUCCESS = "success.auth.register_success"
+
+ # Account deactivation / grace-period deletion
+ ACCOUNT_DEACTIVATED = "success.account.deactivated"
+ ACCOUNT_REACTIVATED = "success.account.reactivated"
diff --git a/app/services/auth_service.py b/app/services/auth_service.py
index 36ab215..d4df456 100644
--- a/app/services/auth_service.py
+++ b/app/services/auth_service.py
@@ -39,10 +39,7 @@
async def register_service(
request: Request, session: AsyncSession, user_create: UserCreate
) -> UserPublic:
- """
- Handle public user registration.
- Orchestrates user creation and any post-registration tasks.
- """
+ """Register a user, audit the event, and send the verification email."""
user = await create_user_service(
request=request, session=session, user_create=user_create, current_user=None
)
@@ -79,6 +76,12 @@ async def register_service(
return UserPublic.model_validate(user)
+# Pre-computed bcrypt hash of a random string used to keep authentication
+# timing constant when the supplied email does not exist in the database.
+# Regenerating on module import is enough — the value itself is not sensitive.
+_DUMMY_PASSWORD_HASH = get_password_hash("unused-timing-safe-placeholder")
+
+
async def authenticate(
request: Request | None, session: AsyncSession, email: str, password: str
) -> User:
@@ -89,14 +92,13 @@ async def authenticate(
"""
user = await get_user_by_email(session, email=email)
- if not user:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail=ErrorMessages.INVALID_CREDENTIALS,
- )
+ # Always run verify_password so the response time does not leak whether
+ # the email exists (email enumeration guard).
+ hashed = user.hashed_password if user else _DUMMY_PASSWORD_HASH
+ password_ok = verify_password(password, hashed)
- if not verify_password(password, user.hashed_password):
- if request:
+ if not user or not password_ok:
+ if user and request:
await log_activity(
session=session,
user_id=user.id,
@@ -111,21 +113,9 @@ async def authenticate(
detail=ErrorMessages.INVALID_CREDENTIALS,
)
- if not user.is_active:
- if request:
- await log_activity(
- session=session,
- user_id=user.id,
- activity_type=ActivityType.LOGIN,
- resource_type=ResourceType.AUTH,
- status=ActivityStatus.FAILURE,
- details={"reason": "user_inactive", "email": email},
- request=request,
- )
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorMessages.USER_INACTIVE,
- )
+ # Accounts in the deletion grace window (is_active=False + deletion_scheduled_at)
+ # are allowed to log in so the frontend can render the "cancel deletion" page.
+ # The ``get_current_active_user`` dep still blocks them from regular endpoints.
if not getattr(user, "is_verified", True):
raise HTTPException(
@@ -188,7 +178,7 @@ async def refresh_token_service(
)
# Check if the token is revoked
- if await is_token_blacklisted(session, refresh_token):
+ if await is_token_blacklisted(refresh_token):
if request:
await log_activity(
session=session,
@@ -204,9 +194,11 @@ async def refresh_token_service(
detail=ErrorMessages.INVALID_TOKEN,
)
- # Check if user exists and is active
+ # Refresh works for users in the deletion grace window too (so they stay
+ # on the cancel-deletion page without repeatedly re-authenticating). Only
+ # hard-deleted users are blocked.
user = await get_user_by_id(session, parsed_user_id)
- if not user or not user.is_active or user.is_deleted:
+ if not user or user.is_deleted:
if request:
await log_activity(
session=session,
@@ -214,7 +206,7 @@ async def refresh_token_service(
activity_type=ActivityType.LOGIN,
resource_type=ResourceType.AUTH,
status=ActivityStatus.FAILURE,
- details={"reason": "user_inactive_or_deleted"},
+ details={"reason": "user_deleted"},
request=request,
)
raise HTTPException(
@@ -235,9 +227,9 @@ async def logout_service(
"""
if refresh_token:
# Check if it was already blacklisted to avoid unique constraint errors
- is_blacklisted = await is_token_blacklisted(session, refresh_token)
+ is_blacklisted = await is_token_blacklisted(refresh_token)
if not is_blacklisted:
- await add_token_to_blacklist(session, refresh_token)
+ await add_token_to_blacklist(refresh_token)
# Log success if possible
user_id = verify_refresh_token(refresh_token)
@@ -259,7 +251,7 @@ async def verify_email_service(
request: Request, session: AsyncSession, token: str
) -> Message:
# Check if token is blacklisted
- if await is_token_blacklisted(session, token):
+ if await is_token_blacklisted(token):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorMessages.INVALID_TOKEN,
@@ -285,7 +277,7 @@ async def verify_email_service(
await update_user(session, user, {"is_verified": True})
# Blacklist the token after successful use
- await add_token_to_blacklist(session, token)
+ await add_token_to_blacklist(token)
await log_activity(
session=session,
@@ -341,7 +333,7 @@ async def reset_password_service(
request: Request, session: AsyncSession, token: str, new_password: str
) -> Message:
# Check if token is blacklisted
- if await is_token_blacklisted(session, token):
+ if await is_token_blacklisted(token):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorMessages.INVALID_TOKEN,
@@ -366,7 +358,7 @@ async def reset_password_service(
await update_user(session, user, {"hashed_password": hashed_password})
# Blacklist the token after successful use
- await add_token_to_blacklist(session, token)
+ await add_token_to_blacklist(token)
await log_activity(
session=session,
diff --git a/app/services/user_activity_service.py b/app/services/user_activity_service.py
index c56c3b3..5d1a29e 100644
--- a/app/services/user_activity_service.py
+++ b/app/services/user_activity_service.py
@@ -1,11 +1,11 @@
import uuid
-from typing import Any
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user_activity import UserActivity
from app.repositories.user_activity import create_user_activity
+from app.schemas.common import ActivityDetails
from app.schemas.user_activity import (
ActivityStatus,
ActivityType,
@@ -19,7 +19,7 @@ async def log_activity(
user_id: uuid.UUID,
activity_type: ActivityType,
resource_type: ResourceType,
- details: dict[str, Any] | None = None,
+ details: ActivityDetails | None = None,
resource_id: uuid.UUID | None = None,
status: ActivityStatus = ActivityStatus.SUCCESS,
request: Request | None = None,
diff --git a/app/services/user_service.py b/app/services/user_service.py
index fc5638f..8e281cf 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -3,15 +3,20 @@
from fastapi import HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
+from app.core.config import settings
+from app.core.email import check_mx_record, is_disposable_email
from app.core.messages.error_message import ErrorMessages
from app.core.messages.success_message import SuccessMessages
from app.core.security import get_password_hash, verify_password
from app.models.user import User
+from app.repositories.token_blacklist import add_token_to_blacklist
from app.repositories.user import (
create_user,
+ deactivate_user,
get_user_by_email,
get_user_by_id,
get_users_with_count,
+ reactivate_user,
soft_delete_user,
update_user,
)
@@ -27,13 +32,21 @@
from app.services.user_activity_service import log_activity
-async def delete_own_account_service(
+async def deactivate_own_account_service(
request: Request, session: AsyncSession, current_user: User, password: str
) -> Message:
+ """Deactivate the current user's account and schedule deletion.
+
+ Verifies the password, starts the grace window configured by
+ ``ACCOUNT_DELETION_GRACE_DAYS``, blacklists the caller's active JWTs so
+ the session cannot be resumed, and logs an audit entry.
"""
- Securely delete own account with password confirmation.
- Uses soft delete.
- """
+ if current_user.deletion_scheduled_at is not None:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ErrorMessages.ACCOUNT_ALREADY_DEACTIVATED,
+ )
+
if not verify_password(password, current_user.hashed_password):
await log_activity(
session=session,
@@ -48,19 +61,58 @@ async def delete_own_account_service(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ErrorMessages.INVALID_PASSWORD,
)
- await soft_delete_user(session, current_user)
+
+ await deactivate_user(
+ session, current_user, grace_days=settings.ACCOUNT_DELETION_GRACE_DAYS
+ )
+
+ # Invalidate the caller's active tokens so the session can't be replayed.
+ access_token = request.cookies.get("access_token")
+ refresh_token = request.cookies.get("refresh_token")
+ if access_token:
+ await add_token_to_blacklist(access_token)
+ if refresh_token:
+ await add_token_to_blacklist(refresh_token)
await log_activity(
session=session,
user_id=current_user.id,
- activity_type=ActivityType.DELETE,
+ activity_type=ActivityType.UPDATE,
resource_type=ResourceType.USER,
resource_id=current_user.id,
- details={"deleted_by": "self"},
+ details={
+ "action": "account_deactivated",
+ "grace_days": settings.ACCOUNT_DELETION_GRACE_DAYS,
+ },
request=request,
)
- return Message(success=True, message=SuccessMessages.USER_DELETED)
+ return Message(success=True, message=SuccessMessages.ACCOUNT_DEACTIVATED)
+
+
+async def reactivate_own_account_service(
+ request: Request, session: AsyncSession, current_user: User
+) -> Message:
+ """Cancel a pending deletion and re-enable the account."""
+ if current_user.deletion_scheduled_at is None:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ErrorMessages.ACCOUNT_NOT_DEACTIVATED,
+ )
+
+ await reactivate_user(session, current_user)
+
+ await log_activity(
+ session=session,
+ user_id=current_user.id,
+ activity_type=ActivityType.UPDATE,
+ resource_type=ResourceType.USER,
+ resource_id=current_user.id,
+ details={"action": "account_reactivated"},
+ request=request,
+ )
+
+ return Message(success=True, message=SuccessMessages.ACCOUNT_REACTIVATED)
async def delete_user_service(
@@ -93,7 +145,20 @@ async def create_user_service(
Business logic to create a new user.
Checks for email availability and hashes the password.
"""
- # 1. Guard check: Email must be unique
+ # 1. Reject disposable / unreachable email domains (same checks the
+ # reset/verify flows use so attackers can't register with throwaway mail).
+ if await is_disposable_email(user_create.email):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ErrorMessages.DISPOSABLE_EMAIL_NOT_ALLOWED,
+ )
+ if not await check_mx_record(user_create.email):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ErrorMessages.INVALID_EMAIL_DOMAIN,
+ )
+
+ # 2. Guard check: Email must be unique
existing_user = await get_user_by_email(session, email=user_create.email)
if existing_user:
if current_user and request:
From f2a5775d79e1625fde2b0c8b1f7337a73148bfe6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?=
<117113383+kemalcalak@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:04:59 +0300
Subject: [PATCH 5/8] feat: Add worker job for hard-deleting expired user
accounts and configure worker settings
---
app/worker/__init__.py | 0
app/worker/jobs/__init__.py | 0
app/worker/jobs/delete_expired_accounts.py | 77 ++++++++++++++++++++++
app/worker/settings.py | 37 +++++++++++
4 files changed, 114 insertions(+)
create mode 100644 app/worker/__init__.py
create mode 100644 app/worker/jobs/__init__.py
create mode 100644 app/worker/jobs/delete_expired_accounts.py
create mode 100644 app/worker/settings.py
diff --git a/app/worker/__init__.py b/app/worker/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/worker/jobs/__init__.py b/app/worker/jobs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/worker/jobs/delete_expired_accounts.py b/app/worker/jobs/delete_expired_accounts.py
new file mode 100644
index 0000000..ec39657
--- /dev/null
+++ b/app/worker/jobs/delete_expired_accounts.py
@@ -0,0 +1,77 @@
+"""Hard-delete accounts whose grace period has elapsed.
+
+Runs on the arq cron schedule defined in ``app.worker.settings``. Safe to
+run across multiple worker replicas — ``get_users_due_for_deletion`` uses
+``FOR UPDATE SKIP LOCKED`` so workers never collide on the same row.
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+from typing import TypedDict
+
+from arq import Retry # noqa: F401 # re-exported for downstream retry policies
+
+from app.core.config import settings
+from app.core.db import AsyncSessionLocal
+from app.repositories.user import (
+ bulk_hard_delete_users,
+ get_users_due_for_deletion,
+)
+from app.schemas.worker import DeletionJobResult
+from app.utils import utc_now
+
+logger = logging.getLogger(__name__)
+
+
+class JobContext(TypedDict, total=False):
+ """Subset of the arq context this job relies on. ``total=False`` since
+ arq populates many runtime keys (job_id, redis, score, enqueue_time...)
+ that we never read here."""
+
+ job_id: str
+
+
+async def delete_expired_accounts(ctx: JobContext) -> DeletionJobResult:
+ """Remove users past their grace window in bounded batches.
+
+ Each batch opens a short-lived transaction so row locks acquired by
+ ``SELECT ... FOR UPDATE SKIP LOCKED`` are released on commit. The loop
+ exits when an empty batch is observed. Individual user failures are
+ logged and skipped — they'll be retried on the next run.
+ """
+ _ = ctx
+ start = time.monotonic()
+ processed = 0
+ failed = 0
+ batch_limit = settings.DELETION_JOB_BATCH_SIZE
+
+ while True:
+ async with AsyncSessionLocal() as session, session.begin():
+ users = await get_users_due_for_deletion(
+ session, now=utc_now(), limit=batch_limit
+ )
+ if not users:
+ break
+
+ ids = [u.id for u in users]
+ try:
+ deleted = await bulk_hard_delete_users(session, ids)
+ processed += deleted
+ except Exception:
+ failed += len(ids)
+ logger.exception(
+ "delete_expired_accounts: batch delete failed",
+ extra={"batch_size": len(ids)},
+ )
+
+ if len(users) < batch_limit:
+ break
+
+ duration_ms = int((time.monotonic() - start) * 1000)
+ result = DeletionJobResult(
+ processed=processed, failed=failed, duration_ms=duration_ms
+ )
+ logger.info("delete_expired_accounts: completed", extra=result.model_dump())
+ return result
diff --git a/app/worker/settings.py b/app/worker/settings.py
new file mode 100644
index 0000000..5bdbccd
--- /dev/null
+++ b/app/worker/settings.py
@@ -0,0 +1,37 @@
+"""arq worker settings.
+
+The worker is populated with real jobs in ``app/worker/jobs``. This module
+wires the Redis connection and cron schedule that the arq CLI expects.
+"""
+
+from __future__ import annotations
+
+from arq.connections import RedisSettings
+from arq.cron import cron
+
+from app.core.config import settings
+from app.worker.jobs.delete_expired_accounts import delete_expired_accounts
+
+
+def _redis_settings() -> RedisSettings:
+ """Build arq RedisSettings from the app Redis URL."""
+ return RedisSettings.from_dsn(settings.REDIS_URL)
+
+
+class WorkerSettings:
+ """Entry point for ``arq app.worker.settings.WorkerSettings``."""
+
+ redis_settings = _redis_settings()
+ functions: list = [delete_expired_accounts]
+ cron_jobs = [
+ cron(
+ delete_expired_accounts,
+ hour={settings.DELETION_JOB_CRON_HOUR},
+ minute={settings.DELETION_JOB_CRON_MINUTE},
+ run_at_startup=False,
+ ),
+ ]
+ max_jobs = 10
+ job_timeout = 600
+ keep_result = 3600
+ health_check_interval = 60
From 21c31f404fd5290467013859e36ae19a8754a76b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?=
<117113383+kemalcalak@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:05:34 +0300
Subject: [PATCH 6/8] refactor: Refactor code structure for improved
readability and maintainability
---
app/main.py | 26 +++++-
docker-compose.yaml | 31 ++++++-
pyproject.toml | 4 +
pytest.ini | 3 +-
uv.lock | 211 ++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 269 insertions(+), 6 deletions(-)
diff --git a/app/main.py b/app/main.py
index 7f943cf..d43a77f 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,3 +1,6 @@
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
import sentry_sdk
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
@@ -17,9 +20,11 @@
from app.core.config import settings
from app.core.messages.error_message import ErrorMessages
from app.core.rate_limit import limiter
+from app.core.redis import close_redis, init_redis
def custom_generate_unique_id(route: APIRoute) -> str:
+ """Generate a stable operationId for OpenAPI clients."""
return f"{route.tags[0]}-{route.name}"
@@ -27,10 +32,21 @@ def custom_generate_unique_id(route: APIRoute) -> str:
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
+@asynccontextmanager
+async def lifespan(_: FastAPI) -> AsyncIterator[None]:
+ """Initialize and dispose shared resources (Redis) for the API process."""
+ await init_redis()
+ try:
+ yield
+ finally:
+ await close_redis()
+
+
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
generate_unique_id_function=custom_generate_unique_id,
+ lifespan=lifespan,
)
# Exception Handlers
@@ -74,8 +90,16 @@ async def origin_check_middleware(request: Request, call_next):
return await call_next(request)
-# Set all CORS enabled origins
+# Set all CORS enabled origins.
+# allow_credentials + "*" is unsafe: some browsers honour it and would let any
+# origin issue authenticated requests. Refuse that combination outright.
if settings.all_cors_origins:
+ if "*" in settings.all_cors_origins:
+ raise RuntimeError(
+ "CORS misconfiguration: wildcard origin '*' cannot be combined "
+ "with credentialed requests. Set explicit origins in "
+ "BACKEND_CORS_ORIGINS / FRONTEND_HOST."
+ )
app.add_middleware(
CORSMiddleware,
allow_origins=settings.all_cors_origins,
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 56797ae..1167cd4 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -30,12 +30,14 @@ services:
db:
condition: service_healthy
restart: true
+ redis:
+ condition: service_healthy
volumes:
- ./app:/app/app # Mount source code for live reloading
- ./alembic.ini:/app/alembic.ini # Mount alembic configuration
env_file:
- .env
- environment:
+ environment: &backend-env
- ENVIRONMENT=${ENVIRONMENT:-development}
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS:-http://localhost:3000,http://localhost:8000}
- SECRET_KEY=${SECRET_KEY?Variable not set}
@@ -45,13 +47,34 @@ services:
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL:-}
- - POSTGRES_SERVER=${POSTGRES_SERVER:-}
- - POSTGRES_PORT=${POSTGRES_PORT:-5432}
+ # Container-internal hostnames override any localhost values in .env
+ - POSTGRES_SERVER=db
+ - POSTGRES_PORT=5432
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- SENTRY_DSN=${SENTRY_DSN:-}
- - REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
+ - REDIS_URL=redis://redis:6379/0
+ - ACCOUNT_DELETION_GRACE_DAYS=${ACCOUNT_DELETION_GRACE_DAYS:-30}
+ - DELETION_JOB_BATCH_SIZE=${DELETION_JOB_BATCH_SIZE:-100}
+ - DELETION_JOB_CRON_HOUR=${DELETION_JOB_CRON_HOUR:-3}
+ - DELETION_JOB_CRON_MINUTE=${DELETION_JOB_CRON_MINUTE:-0}
+
+ worker:
+ build:
+ context: .
+ restart: always
+ command: ["uv", "run", "arq", "app.worker.settings.WorkerSettings"]
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ volumes:
+ - ./app:/app/app
+ env_file:
+ - .env
+ environment: *backend-env
redis:
image: redis:7
diff --git a/pyproject.toml b/pyproject.toml
index ea4c3d6..b572fc5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.18.4",
+ "arq>=0.25.0",
"asyncpg>=0.31.0",
"bcrypt>=5.0.0",
"dnspython>=2.8.0",
@@ -24,9 +25,12 @@ dependencies = [
[dependency-groups]
dev = [
"aiosqlite>=0.22.1",
+ "fakeredis>=2.35.0",
+ "greenlet>=3.3.2",
"httpx>=0.28.1",
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
+ "pytest-cov>=7.1.0",
"ruff>=0.15.2",
]
diff --git a/pytest.ini b/pytest.ini
index 6a57cee..30bf57a 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,3 +1,4 @@
[pytest]
asyncio_mode = auto
-asyncio_default_fixture_loop_scope = session
+asyncio_default_fixture_loop_scope = function
+asyncio_default_test_loop_scope = function
diff --git a/uv.lock b/uv.lock
index 24d7357..a020326 100644
--- a/uv.lock
+++ b/uv.lock
@@ -61,6 +61,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
+[[package]]
+name = "arq"
+version = "0.25.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "redis", extra = ["hiredis"] },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/ae/24a5e6476aba4e931b3b6ec19b6b45a9da2ba048992b4b49f12861a6df6f/arq-0.25.0.tar.gz", hash = "sha256:d176ebadfba920c039dc578814d19b7814d67fa15f82fdccccaedb4330d65dae", size = 288899, upload-time = "2022-12-02T13:21:18.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/47/626ad09333ffc73fdd2ed270d81ddd60befd8f5b3c904be132bc0e7ccede/arq-0.25.0-py3-none-any.whl", hash = "sha256:db072d0f39c0bc06b436db67ae1f315c81abc1527563b828955670531815290b", size = 25774, upload-time = "2022-12-02T13:21:15.874Z" },
+]
+
[[package]]
name = "asyncpg"
version = "0.31.0"
@@ -197,6 +211,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "coverage"
+version = "7.13.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
+ { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
+ { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
+ { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
+ { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
+ { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
+ { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
+ { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
+ { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
+ { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
+ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
+ { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
+ { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
+ { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
+ { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
+ { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
+ { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
+]
+
[[package]]
name = "deprecated"
version = "1.3.1"
@@ -231,6 +329,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
+[[package]]
+name = "fakeredis"
+version = "2.35.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "redis" },
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/26/b9/c40b92cd49155a8ebbdc983cb50c02fc1c87d3a53f19aa420aefb96b00a3/fakeredis-2.35.0.tar.gz", hash = "sha256:5d1a0192c2c559e55b2d05328d86282ddd2079c1712a91e6d1b3010e0dd45ca6", size = 189000, upload-time = "2026-04-09T18:02:14.746Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/43/83508ccf8177a840aec118bf4d20b0c25ddca6ccecd13f1f89caabcb1a45/fakeredis-2.35.0-py3-none-any.whl", hash = "sha256:565d337a5492e8c19be33a89e7acc078374741c65cb6d4413bd8818346b8c252", size = 129578, upload-time = "2026-04-09T18:02:13.264Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.129.2"
@@ -304,6 +415,7 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
+ { name = "arq" },
{ name = "asyncpg" },
{ name = "bcrypt" },
{ name = "dnspython" },
@@ -322,15 +434,19 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "aiosqlite" },
+ { name = "fakeredis" },
+ { name = "greenlet" },
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
+ { name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.18.4" },
+ { name = "arq", specifier = ">=0.25.0" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "bcrypt", specifier = ">=5.0.0" },
{ name = "dnspython", specifier = ">=2.8.0" },
@@ -349,9 +465,12 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "aiosqlite", specifier = ">=0.22.1" },
+ { name = "fakeredis", specifier = ">=2.35.0" },
+ { name = "greenlet", specifier = ">=3.3.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
+ { name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "ruff", specifier = ">=0.15.2" },
]
@@ -432,6 +551,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
@@ -440,6 +560,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
+ { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -448,6 +569,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -456,6 +578,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
@@ -471,6 +594,66 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
+[[package]]
+name = "hiredis"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/97/d6/9bef6dc3052c168c93fbf7e6c0f2b12c45f0f741a2d30fd919096774343a/hiredis-3.3.1.tar.gz", hash = "sha256:da6f0302360e99d32bc2869772692797ebadd536e1b826d0103c72ba49d38698", size = 89101, upload-time = "2026-03-16T15:21:08.092Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/1d/1a7d925d886211948ab9cca44221b1d9dd4d3481d015511e98794e37d369/hiredis-3.3.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:60543f3b068b16a86e99ed96b7fdae71cdc1d8abdfe9b3f82032a555e52ece7e", size = 82023, upload-time = "2026-03-16T15:19:34.157Z" },
+ { url = "https://files.pythonhosted.org/packages/13/2f/a6017fe1db47cd63a4aefc0dd21dd4dcb0c4e857bfbcfaa27329745f24a3/hiredis-3.3.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:2611bfaaadc5e8d43fb7967f9bbf1110c8beaa83aee2f2d812c76f11cfb56c6a", size = 46215, upload-time = "2026-03-16T15:19:35.068Z" },
+ { url = "https://files.pythonhosted.org/packages/77/4b/35a71d088c6934e162aa81c7e289fa3110a3aca84ab695d88dbd488c74a2/hiredis-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e3754ce60e1b11b0afad9a053481ff184d2ee24bea47099107156d1b84a84aa", size = 41861, upload-time = "2026-03-16T15:19:36.32Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/54/904bc723a95926977764fefd6f0d46067579bac38fffc32b806f3f2c05c0/hiredis-3.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e89dabf436ee79b358fd970dcbed6333a36d91db73f27069ca24a02fb138a404", size = 170196, upload-time = "2026-03-16T15:19:37.274Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/01/4e840cd4cb53c28578234708b08fb9ec9e41c2880acc0e269a7264e1b3af/hiredis-3.3.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4f7e242eab698ad0be5a4b2ec616fa856569c57455cc67c625fd567726290e5f", size = 181808, upload-time = "2026-03-16T15:19:38.637Z" },
+ { url = "https://files.pythonhosted.org/packages/87/0d/fc845f06f8203ab76c401d4d2b97f9fb768e644b053a40f441f7dcc71f2d/hiredis-3.3.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53148a4e21057541b6d8e493b2ea1b500037ddf34433c391970036f3cbce00e3", size = 180577, upload-time = "2026-03-16T15:19:39.749Z" },
+ { url = "https://files.pythonhosted.org/packages/52/3a/859afe2620666bf6d58eb977870c47d98af4999d473b50528b323918f3f7/hiredis-3.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25132902d3eff38781e0d54f27a0942ec849e3c07dbdce83c4d92b7e43c8dce", size = 172507, upload-time = "2026-03-16T15:19:40.87Z" },
+ { url = "https://files.pythonhosted.org/packages/60/a8/004349708ad8bf0d188d46049f846d3fe2d4a7a8d0d5a6a8ba024017d8b3/hiredis-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3fb6573efa15a29c12c0c0f7170b14e7c1347fe4bb39b6a15b779f46015cc929", size = 166339, upload-time = "2026-03-16T15:19:41.912Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/fb/bfc6df29381830c99bfd9e97ed3b6d75d9303866a28c23d51ab8c50f63e3/hiredis-3.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:487658e1db83c1ee9fbbac6a43039ea76957767a5987ffb16b590613f9e68297", size = 176766, upload-time = "2026-03-16T15:19:42.981Z" },
+ { url = "https://files.pythonhosted.org/packages/53/e7/f54aaad4559a413ec8b1043a89567a5a1f898426e4091b9af5e0f2120371/hiredis-3.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a1d190790ee39b8b7adeeb10fc4090dc4859eb4e75ed27bd8108710eef18f358", size = 170313, upload-time = "2026-03-16T15:19:44.082Z" },
+ { url = "https://files.pythonhosted.org/packages/60/51/b80394db4c74d4cba342fa4208f690a2739c16f1125c2a62ba1701b8e2b7/hiredis-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a42c7becd4c9ec4ab5769c754eb61112777bdc6e1c1525e2077389e193b5f5aa", size = 167964, upload-time = "2026-03-16T15:19:45.237Z" },
+ { url = "https://files.pythonhosted.org/packages/47/ef/5e438d1e058be57cdc1bafc1b1ec8ab43cc890c61447e88f8b878a0e32c3/hiredis-3.3.1-cp312-cp312-win32.whl", hash = "sha256:17ec8b524055a88b80d76c177dbbbe475a25c17c5bf4b67bdbdbd0629bcae838", size = 20532, upload-time = "2026-03-16T15:19:46.233Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/c6/39994b9c5646e7bf7d5e92170c07fd5f224ae9f34d95ff202f31845eb94b/hiredis-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:0fac4af8515e6cca74fc701169ae4dc9a71a90e9319c9d21006ec9454b43aa2f", size = 22381, upload-time = "2026-03-16T15:19:47.082Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/4b/c7f4d6d6643622f296395269e24b02c69d4ac72822f052b8cae16fa3af03/hiredis-3.3.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:afe3c3863f16704fb5d7c2c6ff56aaf9e054f6d269f7b4c9074c5476178d1aba", size = 82027, upload-time = "2026-03-16T15:19:48.002Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/45/198be960a7443d6eb5045751e929480929c0defbca316ce1a47d15187330/hiredis-3.3.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:f19ee7dc1ef8a6497570d91fa4057ba910ad98297a50b8c44ff37589f7c89d17", size = 46220, upload-time = "2026-03-16T15:19:48.953Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a4/6ab925177f289830008dbe1488a9858675e2e234f48c9c1653bd4d0eaddc/hiredis-3.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09f5e510f637f2c72d2a79fb3ad05f7b6211e057e367ca5c4f97bb3d8c9d71f4", size = 41858, upload-time = "2026-03-16T15:19:49.939Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c8/a0ddbb9e9c27fcb0022f7b7e93abc75727cb634c6a5273ca5171033dac78/hiredis-3.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b46e96b50dad03495447860510daebd2c96fd44ed25ba8ccb03e9f89eaa9d34", size = 170095, upload-time = "2026-03-16T15:19:51.216Z" },
+ { url = "https://files.pythonhosted.org/packages/94/06/618d509cc454912028f71995f3dd6eb54606f0aa8163ff79c5b7ec1f2bda/hiredis-3.3.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b4fe7f38aa8956fcc1cea270e62601e0e11066aff78e384be70fd283d30293b6", size = 181745, upload-time = "2026-03-16T15:19:52.72Z" },
+ { url = "https://files.pythonhosted.org/packages/06/14/75b2deb62a61fc75a41ce1a6a781fe239133bbc88fef404d32a148ad152a/hiredis-3.3.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b96da7e365d6488d2a75266a662cbe3cc14b28c23dd9b0c9aa04b5bc5c20192", size = 180465, upload-time = "2026-03-16T15:19:53.847Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/8c/8e03dcbfde8e2ca3f880fce06ad0877b3f098ed5fdfb17cf3b821a32323a/hiredis-3.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52d5641027d6731bc7b5e7d126a5158a99784a9f8c6de3d97ca89aca4969e9f8", size = 172419, upload-time = "2026-03-16T15:19:54.959Z" },
+ { url = "https://files.pythonhosted.org/packages/03/05/843005d68403a3805309075efc6638360a3ababa6cb4545163bf80c8e7f7/hiredis-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eddeb9a153795cf6e615f9f3cef66a1d573ff3b6ee16df2b10d1d1c2f2baeaa8", size = 166398, upload-time = "2026-03-16T15:19:56.36Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/23/abe2476244fd792f5108009ec0ae666eaa5b2165ca19f2e86638d8324ac9/hiredis-3.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:011a9071c3df4885cac7f58a2623feac6c8e2ad30e6ba93c55195af05ce61ff5", size = 176844, upload-time = "2026-03-16T15:19:57.462Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/47/e1cdccc559b98e548bcff0868c3938d375663418c0adca465895ee1f72e7/hiredis-3.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:264ee7e9cb6c30dc78da4ecf71d74cf14ca122817c665d838eda8b4384bce1b0", size = 170366, upload-time = "2026-03-16T15:19:58.548Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/e1/fda8325f51d06877e8e92500b15d4aff3855b4c3c91dbd9636a82e4591f2/hiredis-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d1434d0bcc1b3ef048bae53f26456405c08aeed9827e65b24094f5f3a6793f1", size = 168023, upload-time = "2026-03-16T15:19:59.727Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/21/2839d1625095989c116470e2b6841bbe1a2a5509585e82a4f3f5cd47f511/hiredis-3.3.1-cp313-cp313-win32.whl", hash = "sha256:f915a34fb742e23d0d61573349aa45d6f74037fde9d58a9f340435eff8d62736", size = 20535, upload-time = "2026-03-16T15:20:00.938Z" },
+ { url = "https://files.pythonhosted.org/packages/84/f9/534c2a89b24445a9a9623beb4697fd72b8c8f16286f6f3bda012c7af004a/hiredis-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:d8e56e0d1fe607bfff422633f313aec9191c3859ab99d11ff097e3e6e068000c", size = 22383, upload-time = "2026-03-16T15:20:01.865Z" },
+ { url = "https://files.pythonhosted.org/packages/03/72/0450d6b449da58120c5497346eb707738f8f67b9e60c28a8ef90133fc81f/hiredis-3.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:439f9a5cc8f9519ce208a24cdebfa0440fef26aa682a40ba2c92acb10a53f5e0", size = 82112, upload-time = "2026-03-16T15:20:02.865Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c0/0be33a29bcd463e6cbb0282515dd4d0cdfe33c30c7afc6d4d8c460e23266/hiredis-3.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3724f0e58c6ff76fd683429945491de71324ab1bc0ad943a8d68cb0932d24075", size = 46238, upload-time = "2026-03-16T15:20:03.896Z" },
+ { url = "https://files.pythonhosted.org/packages/62/f2/f999854bfaf3bcbee0f797f24706c182ecfaca825f6a582f6281a6aa97e0/hiredis-3.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29fe35e3c6fe03204e75c86514f452591957a1e06b05d86e10d795455b71c355", size = 41891, upload-time = "2026-03-16T15:20:04.939Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/c8/cd9ab90fec3a301d864d8ab6167aea387add8e2287969d89cbcd45d6b0e0/hiredis-3.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d42f3a13290f89191568fc113d95a3d2c8759cdd8c3672f021d8b7436f909e75", size = 170485, upload-time = "2026-03-16T15:20:06.284Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/9a/1ddf9ea236a292963146cbaf6722abeb9d503ca47d821267bb8b3b81c4f7/hiredis-3.3.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2afc675b831f7552da41116fffffca4340f387dc03f56d6ec0c7895ab0b59a10", size = 182030, upload-time = "2026-03-16T15:20:07.857Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/b8/e070a1dbf8a1bbb8814baa0b00836fbe3f10c7af8e11f942cc739c64e062/hiredis-3.3.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4106201cd052d9eabe3cb7b5a24b0fe37307792bda4fcb3cf6ddd72f697828e8", size = 180543, upload-time = "2026-03-16T15:20:09.096Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/bb/b5f4f98e44626e2446cd8a52ce6cb1fc1c99786b6e2db3bf09cea97b90cd/hiredis-3.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8887bf0f31e4b550bd988c8863b527b6587d200653e9375cd91eea2b944b7424", size = 172356, upload-time = "2026-03-16T15:20:10.245Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/93/73a77b54ba94e82f76d02563c588d8a062513062675f483a033a43015f2c/hiredis-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ac7697365dbe45109273b34227fee6826b276ead9a4a007e0877e1d3f0fcf21", size = 166433, upload-time = "2026-03-16T15:20:11.789Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/c2/1b2dcbe5dc53a46a8cb05bed67d190a7e30bad2ad1f727ebe154dfeededd/hiredis-3.3.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2b6da6e07359107c653a809b3cff2d9ccaeedbafe33c6f16434aef6f53ce4a2b", size = 177220, upload-time = "2026-03-16T15:20:12.991Z" },
+ { url = "https://files.pythonhosted.org/packages/02/09/f4314cf096552568b5ea785ceb60c424771f4d35a76c410ad39d258f74bc/hiredis-3.3.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ce334915f5d31048f76a42c607bf26687cf045eb1bc852b7340f09729c6a64fc", size = 170475, upload-time = "2026-03-16T15:20:14.519Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/2e/3f56e438efc8fc27ed4a3dbad58c0280061466473ec35d8f86c90c841a84/hiredis-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee11fd431f83d8a5b29d370b9d79a814d3218d30113bdcd44657e9bdf715fc92", size = 167913, upload-time = "2026-03-16T15:20:15.672Z" },
+ { url = "https://files.pythonhosted.org/packages/56/34/053e5ee91d6dc478faac661996d1fd4886c5acb7a1b5ac30e7d3c794bb51/hiredis-3.3.1-cp314-cp314-win32.whl", hash = "sha256:e0356561b4a97c83b9ee3de657a41b8d1a1781226853adaf47b550bb988fda6f", size = 21167, upload-time = "2026-03-16T15:20:17.013Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/33/06776c641d17881a9031e337e81b3b934c38c2adbb83c85062d6b5f83b72/hiredis-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:80aba5f85d6227faee628ae28d1c3b69c661806a0636548ac56c68782606454f", size = 23000, upload-time = "2026-03-16T15:20:17.966Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/5a/94f9a505b2ff5376d4a05fb279b69d89bafa7219dd33f6944026e3e56f80/hiredis-3.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:907f7b5501a534030738f0f27459a612d2266fd0507b007bb8f3e6de08167920", size = 83039, upload-time = "2026-03-16T15:20:19.316Z" },
+ { url = "https://files.pythonhosted.org/packages/93/ae/d3752a8f03a1fca43d402389d2a2d234d3db54c4d1f07f26c1041ca3c5de/hiredis-3.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:de94b409f49eb6a588ebdd5872e826caec417cd77c17af0fb94f2128427f1a2a", size = 46703, upload-time = "2026-03-16T15:20:20.401Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/76/e32c868a2fa23cd82bacaffd38649d938173244a0e717ec1c0c76874dbdd/hiredis-3.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79cd03e7ff550c17758a7520bf437c156d3d4c8bb74214deeafa69cda49c85a4", size = 42379, upload-time = "2026-03-16T15:20:21.705Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/f6/d687d36a74ce6cf448826cf2e8edfc1eb37cc965308f74eb696aa97c69df/hiredis-3.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ffa7ba2e2da1f806f3181b9730b3e87ba9dbfec884806725d4584055ba3faa6", size = 180311, upload-time = "2026-03-16T15:20:23.037Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ac/f520dc0066a62a15aa920c7dd0a2028c213f4862d5f901409ae92ee5d785/hiredis-3.3.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ee37fe8cf081b72dea72f96a0ee604f492ec02252eb77dc26ff6eec3f997b580", size = 190488, upload-time = "2026-03-16T15:20:24.357Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/f5/ae10fff82d0f291e90c41bf10a5d6543a96aae00cccede01bf2b6f7e178d/hiredis-3.3.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bfdeff778d3f7ff449ca5922ab773899e7d31e26a576028b06a5e9cf0ed8c34", size = 189210, upload-time = "2026-03-16T15:20:25.51Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/8f/5be4344e542aa8d349a03d05486c59d9ca26f69c749d11e114bf34b84d50/hiredis-3.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:027ce4fabfeff5af5b9869d5524770877f9061d118bc36b85703ae3faf5aad8e", size = 180971, upload-time = "2026-03-16T15:20:26.631Z" },
+ { url = "https://files.pythonhosted.org/packages/41/a2/29e230226ec2a31f13f8a832fbafe366e263f3b090553ebe49bb4581a7bd/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dcea8c3f53674ae68e44b12e853b844a1d315250ca6677b11ec0c06aff85e86c", size = 175314, upload-time = "2026-03-16T15:20:27.848Z" },
+ { url = "https://files.pythonhosted.org/packages/89/2e/bf241707ad86b9f3ebfbc7ab89e19d5ec243ff92ca77644a383622e8740b/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0b5ff2f643f4b452b0597b7fe6aa35d398cb31d8806801acfafb1558610ea2aa", size = 185652, upload-time = "2026-03-16T15:20:29.364Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/c1/b39170d8bcccd01febd45af4ac6b43ff38e134a868e2ec167a82a036fb35/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3586c8a5f56d34b9dddaaa9e76905f31933cac267251006adf86ec0eef7d0400", size = 179033, upload-time = "2026-03-16T15:20:30.549Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/3a/4fe39a169115434f911abff08ff485b9b6201c168500e112b3f6a8110c0a/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a110d19881ca78a88583d3b07231e7c6864864f5f1f3491b638863ea45fa8708", size = 176126, upload-time = "2026-03-16T15:20:31.958Z" },
+ { url = "https://files.pythonhosted.org/packages/44/99/c1d0b0bc4f9e9150e24beb0dca2e186e32d5e749d0022e0d26453749ed51/hiredis-3.3.1-cp314-cp314t-win32.whl", hash = "sha256:98fd5b39410e9d69e10e90d0330e35650becaa5dd2548f509b9598f1f3c6124d", size = 22028, upload-time = "2026-03-16T15:20:33.33Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d6/191e6741addc97bcf5e755661f8c82f0fd0aa35f07ece56e858da689b57e/hiredis-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ab1f646ff531d70bfd25f01e60708dfa3d105eb458b7dedd9fe9a443039fd809", size = 23811, upload-time = "2026-03-16T15:20:34.292Z" },
+]
+
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -851,6 +1034,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.1"
@@ -924,6 +1121,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" },
]
+[package.optional-dependencies]
+hiredis = [
+ { name = "hiredis" },
+]
+
[[package]]
name = "rich"
version = "14.3.3"
@@ -1078,6 +1280,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" },
]
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.46"
From 79ad1c5515cf776a4ed18b16199d028256acd595 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?=
<117113383+kemalcalak@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:05:55 +0300
Subject: [PATCH 7/8] test: Enhance user account deletion and reactivation
tests with detailed scenarios
---
app/tests/conftest.py | 62 +++++++++++--
app/tests/test_deletion_worker.py | 147 ++++++++++++++++++++++++++++++
app/tests/test_users.py | 123 ++++++++++++++++++++++++-
3 files changed, 318 insertions(+), 14 deletions(-)
create mode 100644 app/tests/test_deletion_worker.py
diff --git a/app/tests/conftest.py b/app/tests/conftest.py
index 01be779..2dd66b5 100644
--- a/app/tests/conftest.py
+++ b/app/tests/conftest.py
@@ -1,11 +1,13 @@
from unittest.mock import AsyncMock, patch
+import fakeredis.aioredis as fakeredis
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import models # noqa: F401
from app.api.deps import get_db
+from app.core import redis as redis_module
from app.core.db import Base
from app.main import app
@@ -23,6 +25,7 @@
async def override_get_db():
+ """Yield a test-bound async DB session."""
async with TestingSessionLocal() as session:
yield session
@@ -32,9 +35,7 @@ async def override_get_db():
@pytest_asyncio.fixture(scope="function", autouse=True)
async def create_test_database():
- """
- Create a fresh database for each test.
- """
+ """Create a fresh database for each test."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
@@ -42,23 +43,64 @@ async def create_test_database():
await conn.run_sync(Base.metadata.drop_all)
+@pytest_asyncio.fixture(scope="function", autouse=True)
+async def fake_redis():
+ """Swap the real Redis client for an in-process fake during tests."""
+ client = fakeredis.FakeRedis(decode_responses=True)
+ redis_module.set_redis_for_testing(client)
+ try:
+ yield client
+ finally:
+ await client.flushall()
+ await client.aclose()
+ redis_module.set_redis_for_testing(None)
+
+
@pytest_asyncio.fixture
async def client() -> AsyncClient:
- """
- TestClient that can be used to make requests to the application.
- """
+ """Async HTTP client wired to the FastAPI app."""
from app.core.config import settings
+ transport = ASGITransport(app=app)
async with AsyncClient(
- transport=ASGITransport(app=app), base_url=f"http://test{settings.API_V1_STR}"
+ transport=transport, base_url=f"http://test{settings.API_V1_STR}"
) as ac:
yield ac
@pytest_asyncio.fixture(autouse=True)
async def mock_email_send():
+ """Prevent real SMTP connections from any caller during tests.
+
+ Patches both the source (``app.core.email.send_email``) and the
+ re-export used by the auth service. Callers that import the function
+ directly still hit the mock.
"""
- Mock the send_email function to avoid sending real emails during tests.
+ with (
+ patch("app.core.email.send_email", new_callable=AsyncMock) as core_mock,
+ patch("app.services.auth_service.send_email", new=core_mock),
+ ):
+ yield core_mock
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def mock_email_validation():
+ """Bypass MX / disposable-domain lookups during tests.
+
+ Tests use invented domains like ``test.com`` that have no MX records,
+ and the register flow now rejects those by default. Patch at every
+ import site so both direct and re-exported callers hit the mock.
"""
- with patch("app.services.auth_service.send_email", new_callable=AsyncMock) as mock:
- yield mock
+ with (
+ patch("app.core.email.check_mx_record", new=AsyncMock(return_value=True)),
+ patch(
+ "app.services.user_service.check_mx_record",
+ new=AsyncMock(return_value=True),
+ ),
+ patch("app.core.email.is_disposable_email", new=AsyncMock(return_value=False)),
+ patch(
+ "app.services.user_service.is_disposable_email",
+ new=AsyncMock(return_value=False),
+ ),
+ ):
+ yield
diff --git a/app/tests/test_deletion_worker.py b/app/tests/test_deletion_worker.py
new file mode 100644
index 0000000..d3c26a6
--- /dev/null
+++ b/app/tests/test_deletion_worker.py
@@ -0,0 +1,147 @@
+"""Tests for the scheduled hard-deletion worker job."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import timedelta
+
+import pytest
+from sqlalchemy import select
+
+from app.core.security import get_password_hash
+from app.models.user import User
+from app.repositories.user import (
+ get_users_due_for_deletion,
+ hard_delete_user,
+)
+from app.tests.conftest import TestingSessionLocal
+from app.utils import utc_now
+from app.worker.jobs.delete_expired_accounts import delete_expired_accounts
+
+
+async def _make_user(
+ email: str,
+ *,
+ scheduled_at=None,
+ is_deleted: bool = False,
+) -> User:
+ """Insert a user with a given deletion schedule for test setup."""
+ async with TestingSessionLocal() as session:
+ user = User(
+ email=email,
+ hashed_password=get_password_hash("password123"),
+ is_active=scheduled_at is None,
+ is_verified=True,
+ is_deleted=is_deleted,
+ deactivated_at=scheduled_at - timedelta(days=30) if scheduled_at else None,
+ deletion_scheduled_at=scheduled_at,
+ )
+ session.add(user)
+ await session.commit()
+ await session.refresh(user)
+ return user
+
+
+@pytest.mark.asyncio
+async def test_past_due_user_gets_deleted(monkeypatch):
+ """A user whose grace period has elapsed is removed by the job."""
+ import app.worker.jobs.delete_expired_accounts as mod
+
+ monkeypatch.setattr(mod, "AsyncSessionLocal", TestingSessionLocal)
+
+ await _make_user("past@test.com", scheduled_at=utc_now() - timedelta(hours=1))
+
+ result = await delete_expired_accounts({})
+ assert result.processed == 1
+ assert result.failed == 0
+
+ async with TestingSessionLocal() as session:
+ remaining = await session.execute(
+ select(User).where(User.email == "past@test.com")
+ )
+ assert remaining.scalars().first() is None
+
+
+@pytest.mark.asyncio
+async def test_future_scheduled_user_is_not_deleted(monkeypatch):
+ """Users still inside the grace window are preserved."""
+ import app.worker.jobs.delete_expired_accounts as mod
+
+ monkeypatch.setattr(mod, "AsyncSessionLocal", TestingSessionLocal)
+
+ await _make_user("future@test.com", scheduled_at=utc_now() + timedelta(days=5))
+
+ result = await delete_expired_accounts({})
+ assert result.processed == 0
+
+ async with TestingSessionLocal() as session:
+ remaining = await session.execute(
+ select(User).where(User.email == "future@test.com")
+ )
+ assert remaining.scalars().first() is not None
+
+
+@pytest.mark.asyncio
+async def test_active_user_is_not_deleted(monkeypatch):
+ """Active users (no schedule) are never touched by the job."""
+ import app.worker.jobs.delete_expired_accounts as mod
+
+ monkeypatch.setattr(mod, "AsyncSessionLocal", TestingSessionLocal)
+
+ await _make_user("active@test.com", scheduled_at=None)
+
+ result = await delete_expired_accounts({})
+ assert result.processed == 0
+
+
+@pytest.mark.asyncio
+async def test_concurrent_runs_do_not_double_delete(monkeypatch):
+ """Two parallel job invocations must delete each user at most once.
+
+ Note: the CI test DB is SQLite in-memory which doesn't honour
+ ``FOR UPDATE SKIP LOCKED``. The assertion therefore checks the stronger
+ invariant (``processed + failed == 1`` per user) rather than locking
+ semantics, which are exercised against real Postgres in staging.
+ """
+ import app.worker.jobs.delete_expired_accounts as mod
+
+ monkeypatch.setattr(mod, "AsyncSessionLocal", TestingSessionLocal)
+
+ await _make_user("race@test.com", scheduled_at=utc_now() - timedelta(hours=1))
+
+ results = await asyncio.gather(
+ delete_expired_accounts({}),
+ delete_expired_accounts({}),
+ return_exceptions=True,
+ )
+
+ from app.schemas.worker import DeletionJobResult
+
+ total_processed = sum(
+ r.processed for r in results if isinstance(r, DeletionJobResult)
+ )
+ assert total_processed == 1
+
+ async with TestingSessionLocal() as session:
+ remaining = await session.execute(
+ select(User).where(User.email == "race@test.com")
+ )
+ assert remaining.scalars().first() is None
+
+
+@pytest.mark.asyncio
+async def test_repository_helpers_return_expected_set():
+ """The repository query surfaces only eligible users."""
+ await _make_user("eligible@test.com", scheduled_at=utc_now() - timedelta(minutes=5))
+ await _make_user("too-early@test.com", scheduled_at=utc_now() + timedelta(days=1))
+ await _make_user("untouched@test.com", scheduled_at=None)
+
+ async with TestingSessionLocal() as session:
+ due = await get_users_due_for_deletion(session, now=utc_now(), limit=10)
+ emails = {u.email for u in due}
+ assert "eligible@test.com" in emails
+ assert "too-early@test.com" not in emails
+ assert "untouched@test.com" not in emails
+
+ for user in due:
+ await hard_delete_user(session, user)
diff --git a/app/tests/test_users.py b/app/tests/test_users.py
index 3c42a90..f7d7c23 100644
--- a/app/tests/test_users.py
+++ b/app/tests/test_users.py
@@ -85,8 +85,8 @@ async def test_update_user_me(auth_client: AsyncClient):
@pytest.mark.asyncio
-async def test_delete_user_me(auth_client: AsyncClient):
- # Attempt delete with wrong password
+async def test_delete_user_me_wrong_password_rejected(auth_client: AsyncClient):
+ """Wrong password must not start the grace window."""
response = await auth_client.request(
"DELETE",
url="/users/me",
@@ -94,7 +94,18 @@ async def test_delete_user_me(auth_client: AsyncClient):
)
assert response.status_code == 401
- # Attempt delete with correct password
+
+@pytest.mark.asyncio
+async def test_delete_user_me_schedules_deletion(auth_client: AsyncClient):
+ """Correct password deactivates the account and schedules deletion."""
+ from datetime import timedelta
+
+ from sqlalchemy import select
+
+ from app.core.config import settings
+ from app.models.user import User
+ from app.tests.conftest import TestingSessionLocal
+
response = await auth_client.request(
"DELETE",
url="/users/me",
@@ -102,5 +113,109 @@ async def test_delete_user_me(auth_client: AsyncClient):
)
assert response.status_code == 200
+ async with TestingSessionLocal() as session:
+ result = await session.execute(
+ select(User).where(User.email == "user_test@test.com")
+ )
+ user = result.scalars().one()
+ assert user.is_active is False
+ assert user.is_deleted is False
+ assert user.deactivated_at is not None
+ assert user.deletion_scheduled_at is not None
+ delta = user.deletion_scheduled_at - user.deactivated_at
+ # Allow 1 second skew between repository timestamps.
+ assert abs(
+ delta - timedelta(days=settings.ACCOUNT_DELETION_GRACE_DAYS)
+ ) < timedelta(seconds=2)
+
+
+@pytest.mark.asyncio
+async def test_delete_user_me_twice_returns_400(auth_client: AsyncClient):
+ """Second deactivate attempt while already pending must fail fast."""
+ first = await auth_client.request(
+ "DELETE", url="/users/me", json={"password": "password123"}
+ )
+ assert first.status_code == 200
+
+ # After deactivate, cookies are cleared by the route. Re-login to get
+ # fresh credentials — deactivated users are allowed to log back in.
+ await auth_client.post(
+ "/auth/login",
+ data={"username": "user_test@test.com", "password": "password123"},
+ )
+
+ second = await auth_client.request(
+ "DELETE", url="/users/me", json={"password": "password123"}
+ )
+ assert second.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_deactivated_user_blocked_from_update(auth_client: AsyncClient):
+ """PATCH /users/me must reject deactivated callers."""
+ await auth_client.request(
+ "DELETE", url="/users/me", json={"password": "password123"}
+ )
+ # Re-login so cookies are present again.
+ await auth_client.post(
+ "/auth/login",
+ data={"username": "user_test@test.com", "password": "password123"},
+ )
+
+ response = await auth_client.patch("/users/me", json={"first_name": "Hacked"})
+ assert response.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_reactivate_cancels_deletion(auth_client: AsyncClient):
+ """Reactivate endpoint restores the account inside the grace window."""
+ from sqlalchemy import select
+
+ from app.models.user import User
+ from app.tests.conftest import TestingSessionLocal
+
+ await auth_client.request(
+ "DELETE", url="/users/me", json={"password": "password123"}
+ )
+ await auth_client.post(
+ "/auth/login",
+ data={"username": "user_test@test.com", "password": "password123"},
+ )
+
+ response = await auth_client.post("/users/me/reactivate")
+ assert response.status_code == 200
+
+ async with TestingSessionLocal() as session:
+ result = await session.execute(
+ select(User).where(User.email == "user_test@test.com")
+ )
+ user = result.scalars().one()
+ assert user.is_active is True
+ assert user.deactivated_at is None
+ assert user.deletion_scheduled_at is None
+
+
+@pytest.mark.asyncio
+async def test_reactivate_on_active_account_returns_400(auth_client: AsyncClient):
+ """Reactivating an account that isn't pending deletion must fail."""
+ response = await auth_client.post("/users/me/reactivate")
+ assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_me_exposes_deletion_schedule(auth_client: AsyncClient):
+ """GET /users/me must include deletion_scheduled_at for deactivated users."""
+ await auth_client.request(
+ "DELETE", url="/users/me", json={"password": "password123"}
+ )
+ await auth_client.post(
+ "/auth/login",
+ data={"username": "user_test@test.com", "password": "password123"},
+ )
+
response = await auth_client.get("/users/me")
- assert response.status_code in (401, 403, 404)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["is_active"] is False
+ assert data["deactivated_at"] is not None
+ assert data["deletion_scheduled_at"] is not None
From 3085651f469f3c08246ad94764da122c137f7703 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?=
<117113383+kemalcalak@users.noreply.github.com>
Date: Sun, 12 Apr 2026 16:16:09 +0300
Subject: [PATCH 8/8] feat: Remove is_deleted and deleted_at columns from user
model and update related logic
---
...84a530_drop_is_deleted_column_from_user.py | 64 +++++++++
...5b4577_drop_deleted_at_column_from_user.py | 42 ++++++
app/api/routes/users.py | 1 +
app/core/messages/success_message.py | 1 -
app/models/user.py | 18 ++-
app/repositories/user.py | 34 ++---
app/schemas/user.py | 1 +
app/services/auth_service.py | 8 +-
app/services/user_service.py | 46 +++---
app/tests/test_deletion_worker.py | 2 -
app/tests/test_users.py | 1 -
app/utils/email_templates.py | 134 ++++++++++++++++++
12 files changed, 288 insertions(+), 64 deletions(-)
create mode 100644 app/alembic/versions/01155384a530_drop_is_deleted_column_from_user.py
create mode 100644 app/alembic/versions/0bb6cf5b4577_drop_deleted_at_column_from_user.py
diff --git a/app/alembic/versions/01155384a530_drop_is_deleted_column_from_user.py b/app/alembic/versions/01155384a530_drop_is_deleted_column_from_user.py
new file mode 100644
index 0000000..8f38a89
--- /dev/null
+++ b/app/alembic/versions/01155384a530_drop_is_deleted_column_from_user.py
@@ -0,0 +1,64 @@
+"""drop is_deleted column from user
+
+Revision ID: 01155384a530
+Revises: a48b0bc6e988
+Create Date: 2026-04-12 15:49:47.623781
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "01155384a530"
+down_revision: str | Sequence[str] | None = "a48b0bc6e988"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Drop is_deleted column; rewrite the deletion-due index without it."""
+ # Partial index references is_deleted in its WHERE clause, so it must be
+ # dropped before the column can go, then recreated with a simpler predicate.
+ op.drop_index(
+ op.f("ix_user_deletion_due"),
+ table_name="user",
+ postgresql_where="((is_deleted = false) AND (deletion_scheduled_at IS NOT NULL))",
+ )
+ op.drop_index(op.f("ix_user_is_deleted"), table_name="user")
+ op.drop_column("user", "is_deleted")
+ op.create_index(
+ "ix_user_deletion_due",
+ "user",
+ ["deletion_scheduled_at"],
+ unique=False,
+ postgresql_where=sa.text(
+ "is_active = false AND deletion_scheduled_at IS NOT NULL"
+ ),
+ )
+
+
+def downgrade() -> None:
+ """Recreate is_deleted column and the original partial index."""
+ op.drop_index(op.f("ix_user_deletion_due"), table_name="user")
+ op.add_column(
+ "user",
+ sa.Column(
+ "is_deleted",
+ sa.BOOLEAN(),
+ autoincrement=False,
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ )
+ op.alter_column("user", "is_deleted", server_default=None)
+ op.create_index(op.f("ix_user_is_deleted"), "user", ["is_deleted"], unique=False)
+ op.create_index(
+ op.f("ix_user_deletion_due"),
+ "user",
+ ["deletion_scheduled_at"],
+ unique=False,
+ postgresql_where="((is_deleted = false) AND (deletion_scheduled_at IS NOT NULL))",
+ )
diff --git a/app/alembic/versions/0bb6cf5b4577_drop_deleted_at_column_from_user.py b/app/alembic/versions/0bb6cf5b4577_drop_deleted_at_column_from_user.py
new file mode 100644
index 0000000..5bb7387
--- /dev/null
+++ b/app/alembic/versions/0bb6cf5b4577_drop_deleted_at_column_from_user.py
@@ -0,0 +1,42 @@
+"""drop deleted_at column from user
+
+Revision ID: 0bb6cf5b4577
+Revises: 01155384a530
+Create Date: 2026-04-12 16:10:20.195589
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = "0bb6cf5b4577"
+down_revision: str | Sequence[str] | None = "01155384a530"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Drop unused deleted_at column.
+
+ The partial index ``ix_user_deletion_due`` is intentionally preserved —
+ autogenerate wants to drop it because the predicate isn't reflected on
+ the model, but the deletion worker depends on it for performance.
+ """
+ op.drop_column("user", "deleted_at")
+
+
+def downgrade() -> None:
+ """Recreate deleted_at column."""
+ op.add_column(
+ "user",
+ sa.Column(
+ "deleted_at",
+ postgresql.TIMESTAMP(timezone=True),
+ autoincrement=False,
+ nullable=True,
+ ),
+ )
diff --git a/app/api/routes/users.py b/app/api/routes/users.py
index 406a018..c97e60b 100644
--- a/app/api/routes/users.py
+++ b/app/api/routes/users.py
@@ -77,6 +77,7 @@ async def delete_user_me(
session=session,
current_user=current_user,
password=body.password,
+ lang=body.lang,
)
response.delete_cookie(key="access_token", path="/")
response.delete_cookie(
diff --git a/app/core/messages/success_message.py b/app/core/messages/success_message.py
index ae29c57..3674947 100644
--- a/app/core/messages/success_message.py
+++ b/app/core/messages/success_message.py
@@ -1,6 +1,5 @@
class SuccessMessages:
# User Specific
- USER_DELETED = "success.user.deleted"
USER_CREATED = "success.user.created"
USER_UPDATED = "success.user.updated"
EMAIL_VERIFIED = "success.user.email_verified"
diff --git a/app/models/user.py b/app/models/user.py
index bb9acd8..de5be44 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -2,7 +2,7 @@
from datetime import datetime
from typing import TYPE_CHECKING
-from sqlalchemy import Boolean, DateTime, String
+from sqlalchemy import Boolean, DateTime, Index, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
@@ -14,6 +14,16 @@
class User(Base):
__tablename__ = "user"
+ __table_args__ = (
+ # Partial index so the deletion worker's scan skips active rows and
+ # rows without a scheduled deletion. Declared here (not only in the
+ # migration) so Alembic autogenerate doesn't keep proposing to drop it.
+ Index(
+ "ix_user_deletion_due",
+ "deletion_scheduled_at",
+ postgresql_where="is_active = false AND deletion_scheduled_at IS NOT NULL",
+ ),
+ )
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
@@ -30,15 +40,9 @@ class User(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
- is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
- deleted_at: Mapped[datetime | None] = mapped_column(
- DateTime(timezone=True), default=None
- )
deactivated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), default=None
)
- # Partial index (defined in migration) avoids indexing rows that will
- # never match the deletion query — see ix_user_deletion_due.
deletion_scheduled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), default=None
)
diff --git a/app/repositories/user.py b/app/repositories/user.py
index a17d41d..399dfbb 100644
--- a/app/repositories/user.py
+++ b/app/repositories/user.py
@@ -10,18 +10,13 @@
async def get_user_by_id(session: AsyncSession, user_id: uuid.UUID) -> User | None:
- """Get a single user by their UUID. Excludes deleted users."""
- user = await session.get(User, user_id)
- if user and user.is_deleted:
- return None
- return user
+ """Get a single user by their UUID."""
+ return await session.get(User, user_id)
async def get_user_by_email(session: AsyncSession, email: str) -> User | None:
- """Get a single user by their email address. Excludes deleted users."""
- statement = (
- select(User).where(User.email == email).where(User.is_deleted.is_(False))
- )
+ """Get a single user by their email address."""
+ statement = select(User).where(User.email == email)
result = await session.execute(statement)
return result.scalars().first()
@@ -29,16 +24,12 @@ async def get_user_by_email(session: AsyncSession, email: str) -> User | None:
async def get_users_with_count(
session: AsyncSession, skip: int = 0, limit: int = 100
) -> tuple[Sequence[User], int]:
- """Get paginated users and total count. Excludes deleted users."""
- count_statement = (
- select(func.count()).select_from(User).where(User.is_deleted.is_(False))
- )
+ """Get paginated users and total count."""
+ count_statement = select(func.count()).select_from(User)
count_result = await session.execute(count_statement)
count = count_result.scalar_one()
- users_statement = (
- select(User).where(User.is_deleted.is_(False)).offset(skip).limit(limit)
- )
+ users_statement = select(User).offset(skip).limit(limit)
users_result = await session.execute(users_statement)
users = users_result.scalars().all()
return users, count
@@ -62,15 +53,6 @@ async def update_user(session: AsyncSession, db_user: User, update_data: dict) -
return db_user
-async def soft_delete_user(session: AsyncSession, user: User) -> None:
- """Mark a user as deleted without physical removal."""
- user.is_deleted = True
- user.deleted_at = utc_now()
- user.is_active = False
- session.add(user)
- await session.commit()
-
-
async def deactivate_user(session: AsyncSession, user: User, grace_days: int) -> User:
"""Start the deactivation grace window for a user.
@@ -120,7 +102,7 @@ async def get_users_due_for_deletion(
statement = (
select(User)
.where(
- User.is_deleted.is_(False),
+ User.is_active.is_(False),
User.deletion_scheduled_at.is_not(None),
User.deletion_scheduled_at <= now,
)
diff --git a/app/schemas/user.py b/app/schemas/user.py
index 82fdf57..4eedf02 100644
--- a/app/schemas/user.py
+++ b/app/schemas/user.py
@@ -80,6 +80,7 @@ class UpdatePassword(BaseModel):
class DeleteAccount(BaseModel):
password: str = Field(min_length=8, max_length=40)
+ lang: Language = Language.EN
# Properties to return via API, id is always required
diff --git a/app/services/auth_service.py b/app/services/auth_service.py
index d4df456..4f77e25 100644
--- a/app/services/auth_service.py
+++ b/app/services/auth_service.py
@@ -198,7 +198,7 @@ async def refresh_token_service(
# on the cancel-deletion page without repeatedly re-authenticating). Only
# hard-deleted users are blocked.
user = await get_user_by_id(session, parsed_user_id)
- if not user or user.is_deleted:
+ if not user:
if request:
await log_activity(
session=session,
@@ -297,7 +297,7 @@ async def recover_password_service(
user = await get_user_by_email(session, email)
# We always return success so as not to leak emails
- if not user or not user.is_active or user.is_deleted:
+ if not user or not user.is_active:
return Message(success=True, message=SuccessMessages.PASSWORD_RESET_SENT)
token = create_password_reset_token(email)
@@ -347,7 +347,7 @@ async def reset_password_service(
)
user = await get_user_by_email(session, email)
- if not user or not user.is_active or user.is_deleted:
+ if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ErrorMessages.USER_NOT_FOUND,
@@ -378,7 +378,7 @@ async def resend_verification_service(
user = await get_user_by_email(session, email)
# We always return success so as not to leak emails
- if not user or not user.is_active or user.is_deleted:
+ if not user or not user.is_active:
return Message(success=True, message=SuccessMessages.VERIFICATION_EMAIL_SENT)
if getattr(user, "is_verified", False):
diff --git a/app/services/user_service.py b/app/services/user_service.py
index 8e281cf..7c9e6e0 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -4,7 +4,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
-from app.core.email import check_mx_record, is_disposable_email
+from app.core.email import check_mx_record, is_disposable_email, send_email
from app.core.messages.error_message import ErrorMessages
from app.core.messages.success_message import SuccessMessages
from app.core.security import get_password_hash, verify_password
@@ -17,11 +17,11 @@
get_user_by_id,
get_users_with_count,
reactivate_user,
- soft_delete_user,
update_user,
)
from app.schemas.msg import Message
from app.schemas.user import (
+ Language,
UserCreate,
UserPublic,
UsersPublic,
@@ -30,10 +30,15 @@
)
from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType
from app.services.user_activity_service import log_activity
+from app.utils.email_templates import generate_account_deactivation_email
async def deactivate_own_account_service(
- request: Request, session: AsyncSession, current_user: User, password: str
+ request: Request,
+ session: AsyncSession,
+ current_user: User,
+ password: str,
+ lang: Language = Language.EN,
) -> Message:
"""Deactivate the current user's account and schedule deletion.
@@ -87,6 +92,21 @@ async def deactivate_own_account_service(
request=request,
)
+ reactivate_link = f"{settings.FRONTEND_HOST}/{lang}/account-deactivated"
+ email_data = generate_account_deactivation_email(
+ reactivate_link=reactivate_link,
+ grace_days=settings.ACCOUNT_DELETION_GRACE_DAYS,
+ project_name=settings.PROJECT_NAME,
+ lang=lang,
+ )
+ await send_email(
+ to=current_user.email,
+ subject=email_data["subject"],
+ body=email_data["html"],
+ plain_text=email_data["plain_text"],
+ user_id=str(current_user.id),
+ )
+
return Message(success=True, message=SuccessMessages.ACCOUNT_DEACTIVATED)
@@ -115,26 +135,6 @@ async def reactivate_own_account_service(
return Message(success=True, message=SuccessMessages.ACCOUNT_REACTIVATED)
-async def delete_user_service(
- request: Request, session: AsyncSession, current_user: User, user_id: uuid.UUID
-) -> Message:
- """Admin-level soft delete for any user."""
- db_user = await get_user_service(session, user_id)
- await soft_delete_user(session, db_user)
-
- await log_activity(
- session=session,
- user_id=current_user.id,
- activity_type=ActivityType.DELETE,
- resource_type=ResourceType.USER,
- resource_id=db_user.id,
- details={"deleted_user_email": db_user.email},
- request=request,
- )
-
- return Message(success=True, message=SuccessMessages.USER_DELETED)
-
-
async def create_user_service(
request: Request | None,
session: AsyncSession,
diff --git a/app/tests/test_deletion_worker.py b/app/tests/test_deletion_worker.py
index d3c26a6..fcf8a48 100644
--- a/app/tests/test_deletion_worker.py
+++ b/app/tests/test_deletion_worker.py
@@ -23,7 +23,6 @@ async def _make_user(
email: str,
*,
scheduled_at=None,
- is_deleted: bool = False,
) -> User:
"""Insert a user with a given deletion schedule for test setup."""
async with TestingSessionLocal() as session:
@@ -32,7 +31,6 @@ async def _make_user(
hashed_password=get_password_hash("password123"),
is_active=scheduled_at is None,
is_verified=True,
- is_deleted=is_deleted,
deactivated_at=scheduled_at - timedelta(days=30) if scheduled_at else None,
deletion_scheduled_at=scheduled_at,
)
diff --git a/app/tests/test_users.py b/app/tests/test_users.py
index f7d7c23..03d8159 100644
--- a/app/tests/test_users.py
+++ b/app/tests/test_users.py
@@ -119,7 +119,6 @@ async def test_delete_user_me_schedules_deletion(auth_client: AsyncClient):
)
user = result.scalars().one()
assert user.is_active is False
- assert user.is_deleted is False
assert user.deactivated_at is not None
assert user.deletion_scheduled_at is not None
delta = user.deletion_scheduled_at - user.deactivated_at
diff --git a/app/utils/email_templates.py b/app/utils/email_templates.py
index 373059e..aa83084 100644
--- a/app/utils/email_templates.py
+++ b/app/utils/email_templates.py
@@ -105,6 +105,140 @@ def generate_password_reset_email(
return {"subject": subject, "html": html, "plain_text": plain_text}
+def generate_account_deactivation_email(
+ reactivate_link: str,
+ grace_days: int,
+ project_name: str,
+ lang: str = Language.EN,
+) -> dict[str, str]:
+ """Generate subject, HTML, and plain text for account deactivation notice."""
+ if lang == Language.TR:
+ subject = "Hesabınız Devre Dışı Bırakıldı"
+ greeting = "Merhaba,"
+ message = (
+ f"Hesabınızı silme isteğiniz alındı. Hesabınız devre dışı bırakıldı ve "
+ f"{grace_days} gün sonra kalıcı olarak silinecek."
+ )
+ cancel_text = (
+ "Fikrinizi değiştirdiyseniz, bu süre içinde giriş yaparak silme "
+ "işlemini iptal edebilirsiniz."
+ )
+ btn_text = "Silme İşlemini İptal Et"
+ link_issue_text = "Buton çalışmıyor mu? Bu bağlantıyı tarayıcınıza kopyalayın:"
+ disclaimer = (
+ "Bu isteği siz yapmadıysanız, lütfen hemen giriş yapıp parolanızı "
+ "değiştirin ve silme işlemini iptal edin."
+ )
+ footer_text = f"© {project_name}. Tüm hakları saklıdır."
+ plain_text = (
+ f"Hesabınız devre dışı bırakıldı ve {grace_days} gün sonra silinecek. "
+ f"İptal etmek için: {reactivate_link}"
+ )
+ else:
+ subject = "Your Account Has Been Deactivated"
+ greeting = "Hi there,"
+ message = (
+ f"We've received your account deletion request. Your account is now "
+ f"deactivated and will be permanently deleted in {grace_days} days."
+ )
+ cancel_text = (
+ "Changed your mind? You can cancel the deletion anytime within this "
+ "window by logging back in."
+ )
+ btn_text = "Cancel Deletion"
+ link_issue_text = (
+ "Button not working? Copy and paste this link into your browser:"
+ )
+ disclaimer = (
+ "If you didn't request this, please log in immediately, change your "
+ "password, and cancel the deletion."
+ )
+ footer_text = f"© {project_name}. All rights reserved."
+ plain_text = (
+ f"Your account has been deactivated and will be deleted in "
+ f"{grace_days} days. To cancel: {reactivate_link}"
+ )
+
+ html = f"""
+
+
+
+
+
+ {subject}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {greeting}
+ {message}
+ {cancel_text}
+
+
+
+
+
+
+
+ {link_issue_text}
+
+ {reactivate_link}
+
+
+
+ |
+
+
+
+ |
+ {project_name}
+ {footer_text}
+ |
+
+
+ |
+
+
+
+"""
+
+ return {"subject": subject, "html": html, "plain_text": plain_text}
+
+
def generate_email_verification_email(
verify_link: str, project_name: str, lang: str = Language.EN
) -> dict[str, str]: