apiResult(api.POST('/api/api_tokens/tokens/revoke-all', { headers })),
});
- const hasLive = props.tokens.some((tok) => tokenStatus(tok) === 'active');
+ // Unseen pages may still contain live tokens.
+ const hasLive =
+ props.pagination.has_more ||
+ props.pagination.offset > 0 ||
+ props.tokens.some((tok) => tokenStatus(tok) === 'active');
async function submit(payload: CreatePayload) {
const ok = await actions.create(payload);
@@ -64,6 +69,7 @@ function Index(props: TokenFormProps) {
>
{t(keys.api_tokens.index.usage)}
+
;
export interface operations {
api_tokens_list_tokens: {
parameters: {
- query?: never;
+ query?: {
+ limit?: number;
+ offset?: number;
+ };
header?: never;
path?: never;
cookie?: never;
@@ -251,6 +254,15 @@ export interface operations {
"application/json": components["schemas"]["TokenView"][];
};
};
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
};
};
api_tokens_create_token: {
@@ -339,7 +351,10 @@ export interface operations {
};
api_tokens_list_all: {
parameters: {
- query?: never;
+ query?: {
+ limit?: number;
+ offset?: number;
+ };
header?: never;
path?: never;
cookie?: never;
@@ -355,6 +370,15 @@ export interface operations {
"application/json": components["schemas"]["TokenView"][];
};
};
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
};
};
api_tokens_create_service_key: {
diff --git a/modules/api_tokens/api_tokens/pages/components/TokenPagination.tsx b/modules/api_tokens/api_tokens/pages/components/TokenPagination.tsx
new file mode 100644
index 00000000..d1580b38
--- /dev/null
+++ b/modules/api_tokens/api_tokens/pages/components/TokenPagination.tsx
@@ -0,0 +1,25 @@
+import { router } from '@inertiajs/react';
+import { keys, useT } from '@simple-module-py/i18n';
+import { Button } from '@simple-module-py/ui/components/ui/button';
+import type { TokenFormProps } from '../types';
+
+export function TokenPagination({ limit, offset, has_more }: TokenFormProps['pagination']) {
+ const { t } = useT();
+ function visit(nextOffset: number) {
+ router.get(window.location.pathname, { limit, offset: nextOffset }, { preserveState: true });
+ }
+ return (
+
+
+
+
+ );
+}
diff --git a/modules/api_tokens/api_tokens/pages/components/useTokenActions.ts b/modules/api_tokens/api_tokens/pages/components/useTokenActions.ts
index 0e842520..b70e3678 100644
--- a/modules/api_tokens/api_tokens/pages/components/useTokenActions.ts
+++ b/modules/api_tokens/api_tokens/pages/components/useTokenActions.ts
@@ -13,7 +13,7 @@ export interface TokenCalls {
revokeAll?: () => Promise>;
}
-/** Create / revoke through the JSON API, then reload the page's `tokens` prop. */
+/** Create / revoke through the JSON API, then reload the tokens and pagination. */
export function useTokenActions({ create: createCall, revoke, revokeAll }: TokenCalls) {
const { t } = useT();
const [created, setCreated] = useState(null);
@@ -30,7 +30,7 @@ export function useTokenActions({ create: createCall, revoke, revokeAll }: Token
return false;
}
setCreated(result.data.token);
- router.reload({ only: ['tokens'] });
+ router.reload({ only: ['tokens', 'pagination'] });
return true;
}
@@ -45,7 +45,7 @@ export function useTokenActions({ create: createCall, revoke, revokeAll }: Token
return;
}
toast.success(t(keys.api_tokens.toasts.revoked));
- router.reload({ only: ['tokens'] });
+ router.reload({ only: ['tokens', 'pagination'] });
}
async function confirmRevokeAll() {
@@ -59,7 +59,7 @@ export function useTokenActions({ create: createCall, revoke, revokeAll }: Token
return;
}
toast.success(t(keys.api_tokens.toasts.revoked_all, { count: result.data.revoked }));
- router.reload({ only: ['tokens'] });
+ router.reload({ only: ['tokens', 'pagination'] });
}
return {
diff --git a/modules/api_tokens/api_tokens/pages/types.ts b/modules/api_tokens/api_tokens/pages/types.ts
index 486c9aa9..3b8c7a2a 100644
--- a/modules/api_tokens/api_tokens/pages/types.ts
+++ b/modules/api_tokens/api_tokens/pages/types.ts
@@ -17,6 +17,7 @@ export interface ApiToken {
/** Props both pages receive for their create form. */
export interface TokenFormProps {
tokens: ApiToken[];
+ pagination: { limit: number; offset: number; has_more: boolean };
scopes: string[];
max_lifetime_days: number;
expiry_presets: number[];
diff --git a/modules/api_tokens/api_tokens/service.py b/modules/api_tokens/api_tokens/service.py
index 8fe2acb0..e7ca8f06 100644
--- a/modules/api_tokens/api_tokens/service.py
+++ b/modules/api_tokens/api_tokens/service.py
@@ -11,8 +11,9 @@
import uuid
from collections.abc import Sequence
-from datetime import UTC, datetime, timedelta
+from datetime import timedelta
+from simple_module_core.datetimes import utcnow as _utcnow
from simple_module_core.user_directory import NullUserDirectory, UserDirectory
from simple_module_db import ALL_TENANTS_OPTION, all_tenants
from sqlalchemy import select
@@ -35,10 +36,6 @@ def __init__(self, key: str, **params: object) -> None:
self.params = params
-def _utcnow() -> datetime:
- return datetime.now(UTC)
-
-
class ApiTokenService:
def __init__(self, db: AsyncSession, directory: UserDirectory | None = None) -> None:
self.db = db
@@ -98,17 +95,24 @@ async def has_usable_account(self, user_id: uuid.UUID) -> bool:
async def revoke_all_for_owner(self, owner_user_id: uuid.UUID) -> int:
"""Revoke every live personal token *owner_user_id* holds, in any tenant."""
- tokens = [t for t in await self.list_for_owner(owner_user_id) if t.revoked_at is None]
+ revoked = 0
+ offset = 0
now = _utcnow()
- for token in tokens:
- token.revoked_at = now
- if tokens:
- with all_tenants():
- await self.db.flush()
- return len(tokens)
+ while tokens := await self.list_for_owner(owner_user_id, offset=offset):
+ live = [t for t in tokens if t.revoked_at is None]
+ for token in live:
+ token.revoked_at = now
+ if live:
+ with all_tenants():
+ await self.db.flush()
+ revoked += len(live)
+ offset += len(tokens)
+ return revoked
- async def list_for_owner(self, owner_user_id: uuid.UUID) -> list[ApiToken]:
- """Every personal token *owner_user_id* holds, in any tenant.
+ async def list_for_owner(
+ self, owner_user_id: uuid.UUID, *, limit: int = 100, offset: int = 0
+ ) -> list[ApiToken]:
+ """A page of personal tokens *owner_user_id* holds, in any tenant.
Across tenants on purpose: a token is its owner's, and one created
while another organisation was active must still be listable — and
@@ -120,14 +124,21 @@ async def list_for_owner(self, owner_user_id: uuid.UUID) -> list[ApiToken]:
col(ApiToken.owner_user_id) == owner_user_id,
col(ApiToken.kind) == TokenKind.PERSONAL,
)
- .order_by(ApiToken.created_at.desc())
+ .order_by(ApiToken.created_at.desc(), ApiToken.id.desc())
+ .limit(limit)
+ .offset(offset)
.execution_options(**{ALL_TENANTS_OPTION: True})
)
return list((await self.db.execute(stmt)).scalars())
- async def list_tenant(self) -> list[ApiToken]:
- """Every token in the active tenant (tenant-filtered, fail-closed)."""
- stmt = select(ApiToken).order_by(ApiToken.created_at.desc())
+ async def list_tenant(self, *, limit: int = 100, offset: int = 0) -> list[ApiToken]:
+ """A page of tokens in the active tenant (tenant-filtered, fail-closed)."""
+ stmt = (
+ select(ApiToken)
+ .order_by(ApiToken.created_at.desc(), ApiToken.id.desc())
+ .limit(limit)
+ .offset(offset)
+ )
return list((await self.db.execute(stmt)).scalars())
async def get_owned(self, token_id: uuid.UUID, owner_user_id: uuid.UUID) -> ApiToken | None:
diff --git a/modules/api_tokens/tests-js/api_tokens_Admin.test.tsx b/modules/api_tokens/tests-js/api_tokens_Admin.test.tsx
index 584075ec..5e7f657f 100644
--- a/modules/api_tokens/tests-js/api_tokens_Admin.test.tsx
+++ b/modules/api_tokens/tests-js/api_tokens_Admin.test.tsx
@@ -43,6 +43,7 @@ function setProps(over: Partial> = {}) {
tenant_required: false,
max_lifetime_days: 365,
expiry_presets: [7, 30],
+ pagination: { limit: 100, offset: 0, has_more: false },
csrf_token: 'csrf-xyz',
...over,
};
diff --git a/modules/api_tokens/tests-js/api_tokens_Index.test.tsx b/modules/api_tokens/tests-js/api_tokens_Index.test.tsx
index 1db860ff..708978f9 100644
--- a/modules/api_tokens/tests-js/api_tokens_Index.test.tsx
+++ b/modules/api_tokens/tests-js/api_tokens_Index.test.tsx
@@ -30,6 +30,7 @@ function setProps(over: Partial> = {}) {
scopes: ['files.read', 'files.write'],
max_lifetime_days: 365,
expiry_presets: [7, 30, 90],
+ pagination: { limit: 100, offset: 0, has_more: false },
csrf_token: 'csrf-abc',
...over,
};
@@ -100,7 +101,7 @@ describe('api_tokens Index', () => {
scopes: ['files.read'],
expires_in_days: 30,
});
- expect(reload).toHaveBeenCalledWith({ only: ['tokens'] });
+ expect(reload).toHaveBeenCalledWith({ only: ['tokens', 'pagination'] });
});
it('removes the secret once dismissed and never renders it after a reload', async () => {
@@ -168,7 +169,7 @@ describe('api_tokens Index', () => {
await waitFor(() => expect(toast.success).toHaveBeenCalledWith(keys.api_tokens.toasts.revoked));
expect((await sent(fetchMock)).path).toBe('/api/api_tokens/tokens/t1/revoke');
- expect(reload).toHaveBeenCalledWith({ only: ['tokens'] });
+ expect(reload).toHaveBeenCalledWith({ only: ['tokens', 'pagination'] });
});
it('toasts an error and does not reload when revoke fails', async () => {
@@ -201,4 +202,13 @@ describe('api_tokens Index', () => {
await waitFor(() => expect(screen.queryByText(ra.description)).toBeNull());
expect(fetchMock).not.toHaveBeenCalled();
});
+
+ it('keeps revoke-all available when other pages may hold live tokens', () => {
+ setProps({
+ tokens: [token({ revoked_at: '2026-02-01T00:00:00Z' })],
+ pagination: { limit: 1, offset: 0, has_more: true },
+ });
+ render();
+ expect(screen.getByRole('button', { name: ra.button })).toBeEnabled();
+ });
});
diff --git a/modules/api_tokens/tests/test_api_tokens_pagination.py b/modules/api_tokens/tests/test_api_tokens_pagination.py
new file mode 100644
index 00000000..a47a8a1e
--- /dev/null
+++ b/modules/api_tokens/tests/test_api_tokens_pagination.py
@@ -0,0 +1,66 @@
+"""Bound token lists without truncating revoke-all (#640)."""
+
+import uuid
+
+import pytest
+from _api_tokens_support import INERTIA_HEADERS, PAT_URL, create_pat
+from api_tokens.constants import TokenKind
+from api_tokens.service import ApiTokenService
+from simple_module_db.tenancy import tenant_context
+
+
+async def test_service_pages_and_revoke_all(db_session):
+ service = ApiTokenService(db_session)
+ owner = uuid.uuid4()
+ with tenant_context("pagination"):
+ made = []
+ for i in range(105):
+ token, _ = await service.create(
+ kind=TokenKind.PERSONAL,
+ name=str(i),
+ scopes=["api_tokens.use"],
+ expires_in_days=1,
+ max_lifetime_days=1,
+ owner_user_id=owner,
+ )
+ made.append(token)
+ assert len(await service.list_tenant()) == 100
+ assert len(await service.list_for_owner(owner)) == 100
+ for listing in (service.list_tenant, lambda **kw: service.list_for_owner(owner, **kw)):
+ first = await listing(limit=3)
+ second = await listing(limit=3, offset=3)
+ assert len(first) == len(second) == 3
+ assert not {t.id for t in first} & {t.id for t in second}
+ assert await service.revoke_all_for_owner(owner) == 105
+ assert all(t.revoked_at is not None for t in made)
+ assert await service.revoke_all_for_owner(owner) == 0
+
+
+@pytest.mark.parametrize("url", [PAT_URL, "/api/api_tokens/admin/tokens"])
+async def test_http_pages_and_bounds(actor, url):
+ async with actor(admin=True) as a:
+ for _ in range(3):
+ await create_pat(a.client, ["api_tokens.use"])
+ first = await a.client.get(url, params={"limit": 1})
+ second = await a.client.get(url, params={"limit": 1, "offset": 1})
+ assert first.status_code == second.status_code == 200
+ assert len(first.json()) == len(second.json()) == 1
+ assert first.json()[0]["id"] != second.json()[0]["id"]
+ for params in ({"limit": 0}, {"limit": 101}, {"offset": -1}):
+ assert (await a.client.get(url, params=params)).status_code == 422
+
+
+@pytest.mark.parametrize("url", ["/api-tokens/", "/admin/api-tokens/"])
+async def test_pages_expose_navigation_without_extra_row(actor, url):
+ async with actor(admin=True) as a:
+ for _ in range(3):
+ await create_pat(a.client, ["api_tokens.use"])
+ first = await a.client.get(url, params={"limit": 2}, headers=INERTIA_HEADERS)
+ assert first.status_code == 200
+ props = first.json()["props"]
+ assert len(props["tokens"]) == 2
+ assert props["pagination"] == {"limit": 2, "offset": 0, "has_more": True}
+ last = await a.client.get(url, params={"limit": 2, "offset": 2}, headers=INERTIA_HEADERS)
+ props = last.json()["props"]
+ assert len(props["tokens"]) == 1
+ assert props["pagination"] == {"limit": 2, "offset": 2, "has_more": False}
diff --git a/modules/background_tasks/background_tasks/outbox_clock.py b/modules/background_tasks/background_tasks/outbox_clock.py
index 1615e424..8e8c30fd 100644
--- a/modules/background_tasks/background_tasks/outbox_clock.py
+++ b/modules/background_tasks/background_tasks/outbox_clock.py
@@ -22,6 +22,8 @@
from datetime import UTC, datetime
from typing import Any
+from simple_module_core.datetimes import aware as _aware
+from simple_module_core.datetimes import utcnow
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -30,10 +32,6 @@ def _is_postgres(session: Any) -> bool:
return session.get_bind().dialect.name == "postgresql"
-def _aware(value: datetime) -> datetime:
- return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
-
-
async def db_now(session: Any) -> datetime:
"""The database's current time, as an aware UTC ``datetime``.
@@ -41,7 +39,7 @@ async def db_now(session: Any) -> datetime:
hold either).
"""
if not _is_postgres(session):
- return datetime.now(UTC)
+ return utcnow()
stmt = select(func.clock_timestamp())
if isinstance(session, Session):
value = session.execute(stmt).scalar_one()
diff --git a/modules/sharing/sharing/service.py b/modules/sharing/sharing/service.py
index 676cf2fe..9067af45 100644
--- a/modules/sharing/sharing/service.py
+++ b/modules/sharing/sharing/service.py
@@ -9,9 +9,10 @@
from __future__ import annotations
import uuid
-from datetime import UTC, datetime
from typing import Any
+from simple_module_core.datetimes import aware as _aware
+from simple_module_core.datetimes import utcnow
from simple_module_core.resources import (
ACTION_SHARE,
ResourcePrincipal,
@@ -20,6 +21,7 @@
)
from simple_module_core.user_directory import UserDirectory
from simple_module_db.resource_policy import load_authorized
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
@@ -49,10 +51,6 @@ def __init__(self, status_code: int, detail: str) -> None:
self.detail = detail
-def _aware(value: datetime) -> datetime:
- return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
-
-
class SharingService:
def __init__(
self,
@@ -93,7 +91,7 @@ async def grant(self, principal: ResourcePrincipal, data: ShareGrant) -> Resourc
resource_type = self._type(data.resource_type)
if data.role not in resource_type.shareable_actions:
raise SharingError(422, ERR_BAD_ROLE)
- if data.expires_at is not None and _aware(data.expires_at) <= datetime.now(UTC):
+ if data.expires_at is not None and _aware(data.expires_at) <= utcnow():
raise SharingError(422, ERR_EXPIRED)
row = await self._authorize(principal, resource_type, data.resource_id)
grantee = (await self.directory.get_many(self.db, [data.subject_id])).get(data.subject_id)
@@ -112,8 +110,17 @@ async def grant(self, principal: ResourcePrincipal, data: ShareGrant) -> Resourc
subject_id=grantee.id,
role=data.role,
granted_by=principal.user_id or "",
+ expires_at=data.expires_at,
)
- self.db.add(share)
+ try:
+ async with self.db.begin_nested():
+ self.db.add(share)
+ await self.db.flush()
+ except IntegrityError:
+ # Another grant may have won the unique-key race.
+ share = await self._existing(data.resource_type, resource_id, grantee.id)
+ if share is None:
+ raise
share.role = data.role
share.expires_at = data.expires_at
share.granted_by = principal.user_id or ""
diff --git a/modules/sharing/tests/test_sharing_service.py b/modules/sharing/tests/test_sharing_service.py
index 21fef5f6..3bbf4761 100644
--- a/modules/sharing/tests/test_sharing_service.py
+++ b/modules/sharing/tests/test_sharing_service.py
@@ -204,3 +204,24 @@ async def test_expired_share_can_be_renewed_without_duplicate_grants(env):
assert renewed.id == share.id
assert await _visible(service.db, FRIEND, ACTION_EDIT) == ["n"]
assert len(await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)) == 1
+
+
+async def test_duplicate_insert_race_reuses_winner_and_preserves_transaction(env, monkeypatch):
+ service, note_id = env
+ with tenant_context("t1"):
+ winner = await service.grant(_who(OWNER), _grant(note_id))
+ original = service._existing
+ calls = 0
+
+ async def miss_once(*args):
+ nonlocal calls
+ calls += 1
+ return None if calls == 1 else await original(*args)
+
+ # Simulate a concurrent winner becoming visible after our initial lookup.
+ monkeypatch.setattr(service, "_existing", miss_once)
+ result = await service.grant(_who(OWNER), _grant(note_id, role=ACTION_EDIT))
+ assert result.id == winner.id
+ assert result.role == ACTION_EDIT
+ assert len(await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)) == 1
+ assert await _visible(service.db, FRIEND, ACTION_EDIT) == ["n"]
diff --git a/modules/site_lock/site_lock/rate_limit.py b/modules/site_lock/site_lock/rate_limit.py
index 4df91f43..dfad22e6 100644
--- a/modules/site_lock/site_lock/rate_limit.py
+++ b/modules/site_lock/site_lock/rate_limit.py
@@ -32,17 +32,32 @@ def __init__(
self._window = window_seconds
self._cooldown = cooldown_seconds
self._buckets: dict[str, _Bucket] = {}
+ self._next_sweep = 0.0
@staticmethod
def _now(now: float | None) -> float:
return time.monotonic() if now is None else now
+ def _sweep(self, moment: float) -> None:
+ if moment < self._next_sweep:
+ return
+ self._buckets = {
+ key: bucket
+ for key, bucket in self._buckets.items()
+ if bucket.blocked_until > moment
+ or (bucket.failures and moment - bucket.failures[-1] < self._window)
+ }
+ self._next_sweep = moment + self._window
+
def is_blocked(self, key: str, *, now: float | None = None) -> bool:
+ moment = self._now(now)
+ self._sweep(moment)
bucket = self._buckets.get(key)
- return bucket is not None and bucket.blocked_until > self._now(now)
+ return bucket is not None and bucket.blocked_until > moment
def record_failure(self, key: str, *, now: float | None = None) -> None:
moment = self._now(now)
+ self._sweep(moment)
bucket = self._buckets.setdefault(key, _Bucket())
bucket.failures = [t for t in bucket.failures if moment - t < self._window]
bucket.failures.append(moment)
diff --git a/modules/site_lock/tests/test_site_lock_rate_limit.py b/modules/site_lock/tests/test_site_lock_rate_limit.py
index d270aefb..5412def3 100644
--- a/modules/site_lock/tests/test_site_lock_rate_limit.py
+++ b/modules/site_lock/tests/test_site_lock_rate_limit.py
@@ -58,3 +58,15 @@ def test_reset_clears_a_block() -> None:
lim.record_failure("1.2.3.4", now=float(i))
lim.reset("1.2.3.4")
assert lim.is_blocked("1.2.3.4", now=3.0) is False
+
+
+def test_expired_keys_are_collected_without_dropping_live_cooldowns():
+ lim = _limiter()
+ lim.record_failure("stale", now=0)
+ for i in range(3):
+ lim.record_failure("blocked", now=90 + i)
+ lim.record_failure("fresh", now=101)
+ assert "stale" not in lim._buckets
+ assert lim.is_blocked("blocked", now=101)
+ lim.is_blocked("visitor", now=700)
+ assert not lim._buckets
diff --git a/modules/tenants/tenants/invitations.py b/modules/tenants/tenants/invitations.py
index faaeb818..d9b1e008 100644
--- a/modules/tenants/tenants/invitations.py
+++ b/modules/tenants/tenants/invitations.py
@@ -8,9 +8,11 @@
import hashlib
import secrets
-from datetime import UTC, datetime, timedelta
+from datetime import timedelta
from functools import partial
+from simple_module_core.datetimes import aware as _aware
+from simple_module_core.datetimes import utcnow as _now
from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,15 +33,6 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
-def _now() -> datetime:
- return datetime.now(UTC)
-
-
-def _aware(value: datetime) -> datetime:
- # SQLite hands back naive datetimes even for timezone=True columns.
- return value if value.tzinfo else value.replace(tzinfo=UTC)
-
-
def _pending_clause(tenant_id: str):
return (
(Invitation.tenant_id == tenant_id)
diff --git a/modules/users/tests/test_users_update_schema.py b/modules/users/tests/test_users_update_schema.py
new file mode 100644
index 00000000..caf5d4d8
--- /dev/null
+++ b/modules/users/tests/test_users_update_schema.py
@@ -0,0 +1,11 @@
+"""Self-service updates cannot carry account administration fields (#640)."""
+
+import pytest
+from users.contracts.schemas import UserUpdate
+
+
+@pytest.mark.parametrize("field", ["is_superuser", "is_active", "is_verified"])
+def test_user_update_drops_privileged_fields(field):
+ update = UserUpdate.model_validate({field: True, "full_name": "Alice"})
+ assert update.model_dump(exclude_unset=True) == {"full_name": "Alice"}
+ assert field not in UserUpdate.model_json_schema()["properties"]
diff --git a/modules/users/users/auth_local/lockout/__init__.py b/modules/users/users/auth_local/lockout/__init__.py
index 32620169..5d0368f0 100644
--- a/modules/users/users/auth_local/lockout/__init__.py
+++ b/modules/users/users/auth_local/lockout/__init__.py
@@ -29,10 +29,11 @@
from __future__ import annotations
import math
-from datetime import UTC, datetime, timedelta
+from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from fastapi import Request
+from simple_module_core.datetimes import utcnow as _utcnow
from simple_module_core.rate_limit import RateSpec
from simple_module_db import independent_session
from sqlalchemy import case, func, select, update
@@ -50,10 +51,6 @@
LOCKED_DETAIL = LOGIN_LOCKED_DETAIL
-def _utcnow() -> datetime:
- return datetime.now(UTC)
-
-
def login_attempt(request: Request, username: str) -> LoginAttempt:
"""The :class:`LoginAttempt` for *username* arriving on *request*."""
return LoginAttempt.build(
diff --git a/modules/users/users/contracts/schemas.py b/modules/users/users/contracts/schemas.py
index 7b28a47f..abc4960e 100644
--- a/modules/users/users/contracts/schemas.py
+++ b/modules/users/users/contracts/schemas.py
@@ -63,9 +63,6 @@ class UserCreate(CreateUpdateDictModel, SQLModel):
class UserUpdate(CreateUpdateDictModel, SQLModel):
password: str | None = None
email: EmailStr | None = None
- is_active: bool | None = None
- is_superuser: bool | None = None
- is_verified: bool | None = None
full_name: str | None = None