auth: affirmative versioned sign-up acknowledgement (EN + IT)
Replaces the passive "by signing in you agree…" paragraph on /login with
an active un-pre-ticked checkbox over three short statements that the
user actively accepts. Each acceptance is recorded against a code-level
version pointer, so a future wording revision bumps the version and
prompts every user again.
- New app/legal.py::ACKNOWLEDGEMENT_VERSION (currently 1).
- New UserAcknowledgement model + migration 0027 (user_id FK CASCADE,
version, lang, accepted_at, composite index on user_id+version).
- auth_service: has_acknowledged_current() and record_acknowledgement()
helpers; POST /login validates the checkbox, falls through to a 400
with the localised error otherwise, and writes a row iff the user has
no current-version row (so existing-already-accepted users don't
produce duplicates).
- GET /login: language detection mirrors the landing's
detect_public_lang(); ?lang=en|it overrides; stamps the rtm.lang
cookie; passes the locale dict + version into the template.
- login.html: EN/IT pill, localised lede/banner/legal footer, required
checkbox in an acknowledgement block, hidden lang+ack_version fields.
Submit disabled until the box is ticked (UX polish; the server check
is what carries weight).
- locales/{en,it}.yaml: new auth.ack.* section with TODO(legal) marker.
Wording matches the brief's substance pending solicitor sign-off.
- tests/test_signup_acknowledgement.py: 10 tests (EN + IT rejection,
one row per acceptance, displayed-lang recorded, idempotent on
current version, version-bump writes new row, helper unit tests).
The acknowledgement strengthens the user-civil-claim vector — combined
with the liability cap and the Ltd, it makes "I was misled into thinking
this was advice" much harder to argue. It does NOT move the regulatory
perimeter, which is governed by the content discipline shipped in
47dce1a. Belt-and-braces, not a substitute.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
47dce1a1a4
commit
f3ac65f8f7
9 changed files with 714 additions and 13 deletions
61
alembic/versions/0027_user_acknowledgements.py
Normal file
61
alembic/versions/0027_user_acknowledgements.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""user_acknowledgements: affirmative sign-up acknowledgement audit table.
|
||||||
|
|
||||||
|
Revision ID: 0027
|
||||||
|
Revises: 0026
|
||||||
|
Create Date: 2026-05-29
|
||||||
|
|
||||||
|
One row per (user, version) acceptance of the /login acknowledgement.
|
||||||
|
See app/models.py::UserAcknowledgement and app/legal.py for context.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0027"
|
||||||
|
down_revision: Union[str, None] = "0026"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user_acknowledgements",
|
||||||
|
sa.Column(
|
||||||
|
"id",
|
||||||
|
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
|
||||||
|
primary_key=True, autoincrement=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("version", sa.SmallInteger(), nullable=False),
|
||||||
|
sa.Column("lang", sa.String(length=8), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"accepted_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_user_acknowledgements_user_version",
|
||||||
|
"user_acknowledgements", ["user_id", "version"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_user_acknowledgements_accepted_at",
|
||||||
|
"user_acknowledgements", ["accepted_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_user_acknowledgements_accepted_at",
|
||||||
|
table_name="user_acknowledgements",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_user_acknowledgements_user_version",
|
||||||
|
table_name="user_acknowledgements",
|
||||||
|
)
|
||||||
|
op.drop_table("user_acknowledgements")
|
||||||
19
app/legal.py
Normal file
19
app/legal.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Legal-surface constants.
|
||||||
|
|
||||||
|
Currently a one-liner: the version of the sign-up acknowledgement text
|
||||||
|
the user actively accepts at ``/login``.
|
||||||
|
|
||||||
|
Bumping this constant re-prompts every existing user on their next
|
||||||
|
sign-in, because the new value won't match their stored
|
||||||
|
``user_acknowledgements.version`` rows. The recorded row is what gives
|
||||||
|
the acknowledgement its evidentiary weight; the constant is the pointer
|
||||||
|
that says "the current canonical text is at version N". The text itself
|
||||||
|
lives in ``app/locales/{en,it}.yaml`` under ``auth.ack``.
|
||||||
|
|
||||||
|
When the solicitor finalises wording, edit the YAML files **and** bump
|
||||||
|
this number in the same change so the version pointer stays accurate.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
ACKNOWLEDGEMENT_VERSION: int = 1
|
||||||
|
|
@ -118,3 +118,17 @@ footer:
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
description: "Understand markets. Don't gamble on them."
|
description: "Understand markets. Don't gamble on them."
|
||||||
|
|
||||||
|
# TODO(legal): final wording pending solicitor sign-off. Bumping
|
||||||
|
# ACKNOWLEDGEMENT_VERSION (app/legal.py) re-prompts every existing user
|
||||||
|
# on next sign-in and writes a fresh row at the new version.
|
||||||
|
auth:
|
||||||
|
ack:
|
||||||
|
heading: "Before you create an account, please confirm:"
|
||||||
|
items:
|
||||||
|
- "This service is editorial and educational commentary on public market data. It is not financial, investment, or tax advice and is not a personal recommendation."
|
||||||
|
- "The operator is not authorised or regulated to provide investment advice. Nothing here is a recommendation to buy, sell, or hold any investment."
|
||||||
|
- "I make my own investment decisions and am solely responsible for them. For personal advice I will consult a regulated adviser."
|
||||||
|
checkbox_label: "I have read and accept these statements."
|
||||||
|
error_required: "Please tick the box to confirm the acknowledgement before continuing."
|
||||||
|
lang_switch_aria: "Language"
|
||||||
|
|
|
||||||
|
|
@ -124,3 +124,17 @@ footer:
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
description: "Capisci i mercati. Non scommetterci sopra."
|
description: "Capisci i mercati. Non scommetterci sopra."
|
||||||
|
|
||||||
|
# TODO(legal): final wording pending solicitor sign-off. Bumping
|
||||||
|
# ACKNOWLEDGEMENT_VERSION (app/legal.py) re-prompts every existing user
|
||||||
|
# on next sign-in and writes a fresh row at the new version.
|
||||||
|
auth:
|
||||||
|
ack:
|
||||||
|
heading: "Prima di creare un account, conferma:"
|
||||||
|
items:
|
||||||
|
- "Questo servizio è commento editoriale ed educativo su dati di mercato pubblici. Non è consulenza finanziaria, di investimento o fiscale e non è una raccomandazione personalizzata."
|
||||||
|
- "L'operatore non è autorizzato né regolamentato a fornire consulenza in materia di investimenti. Nulla qui è una raccomandazione di acquisto, vendita o detenzione di alcuno strumento finanziario."
|
||||||
|
- "Prendo da solo le mie decisioni di investimento e ne sono unico responsabile. Per una consulenza personalizzata mi rivolgerò a un consulente regolamentato."
|
||||||
|
checkbox_label: "Ho letto e accetto queste dichiarazioni."
|
||||||
|
error_required: "Spunta la casella per confermare prima di continuare."
|
||||||
|
lang_switch_aria: "Lingua"
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,35 @@ class ReviewerVerdict(Base):
|
||||||
model: Mapped[str | None] = mapped_column(String(64))
|
model: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class UserAcknowledgement(Base):
|
||||||
|
"""Append-only record of a user actively accepting the sign-up
|
||||||
|
acknowledgement at ``/login``. See app/legal.py for the version
|
||||||
|
constant and app/locales/{en,it}.yaml::auth.ack for the canonical text.
|
||||||
|
|
||||||
|
One row per (user, version) acceptance. The value of this table is
|
||||||
|
evidentiary: 'user X accepted the version-N text on date D, in the
|
||||||
|
language they were shown'. A future wording revision bumps
|
||||||
|
ACKNOWLEDGEMENT_VERSION; existing users see the prompt again on next
|
||||||
|
sign-in and a new row is written at the new version."""
|
||||||
|
__tablename__ = "user_acknowledgements"
|
||||||
|
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
version: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
||||||
|
# Language the user actually saw — what they accepted is keyed to it.
|
||||||
|
lang: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
accepted_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, nullable=False, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_user_acknowledgements_user_version", "user_id", "version"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Portfolio / PortfolioSnapshot / Position removed in Phase G —
|
# Portfolio / PortfolioSnapshot / Position removed in Phase G —
|
||||||
# holdings live in the browser, the server stores only the anonymous
|
# holdings live in the browser, the server stores only the anonymous
|
||||||
# ticker universe + public market data.
|
# ticker universe + public market data.
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,38 @@ from app.auth import (
|
||||||
)
|
)
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session, utcnow
|
from app.db import get_session, utcnow
|
||||||
|
from app.legal import ACKNOWLEDGEMENT_VERSION
|
||||||
from app.logging import get_logger
|
from app.logging import get_logger
|
||||||
from app.services.auth_service import AuthError, get_or_create_user, get_user
|
from app.services.auth_service import (
|
||||||
|
AuthError,
|
||||||
|
get_or_create_user,
|
||||||
|
get_user,
|
||||||
|
has_acknowledged_current,
|
||||||
|
record_acknowledgement,
|
||||||
|
)
|
||||||
from app.services import otp_service, referral_service
|
from app.services import otp_service, referral_service
|
||||||
from app.services.email_service import EmailSendError, send_otp, send_welcome_email
|
from app.services.email_service import EmailSendError, send_otp, send_welcome_email
|
||||||
|
from app.services.locales import ACTIVE_PUBLIC_LANGS, detect_public_lang, get_locale
|
||||||
from app.templates_env import templates
|
from app.templates_env import templates
|
||||||
|
|
||||||
|
|
||||||
|
_LANG_COOKIE = "rtm.lang"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_login_lang(request: Request, explicit: str | None) -> str:
|
||||||
|
"""Resolve the language to render /login in. An explicit ?lang= wins
|
||||||
|
(used by the on-page EN/IT pill); otherwise the same detection
|
||||||
|
landing.py uses — cookie → Accept-Language → cf-ipcountry → en."""
|
||||||
|
if explicit and explicit in ACTIVE_PUBLIC_LANGS:
|
||||||
|
return explicit
|
||||||
|
return detect_public_lang(
|
||||||
|
cookie_lang=request.cookies.get(_LANG_COOKIE),
|
||||||
|
accept_language=request.headers.get("accept-language"),
|
||||||
|
cf_country=request.headers.get("cf-ipcountry"),
|
||||||
|
user_lang=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
log = get_logger("auth_router")
|
log = get_logger("auth_router")
|
||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
@ -111,6 +136,7 @@ async def login_page(
|
||||||
next: str | None = None,
|
next: str | None = None,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
ref: str | None = None,
|
ref: str | None = None,
|
||||||
|
lang: str | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
# If a valid referral code is supplied, surface a small "invited"
|
# If a valid referral code is supplied, surface a small "invited"
|
||||||
|
|
@ -121,15 +147,27 @@ async def login_page(
|
||||||
await referral_service.lookup_referrer(session, ref_norm)
|
await referral_service.lookup_referrer(session, ref_norm)
|
||||||
if ref_norm else None
|
if ref_norm else None
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(
|
resolved_lang = _resolve_login_lang(request, lang)
|
||||||
|
response = templates.TemplateResponse(
|
||||||
request, "login.html",
|
request, "login.html",
|
||||||
{
|
{
|
||||||
"next_path": _safe_next(next),
|
"next_path": _safe_next(next),
|
||||||
"error": error,
|
"error": error,
|
||||||
"ref": ref_norm if referrer else None,
|
"ref": ref_norm if referrer else None,
|
||||||
"referrer_present": referrer is not None,
|
"referrer_present": referrer is not None,
|
||||||
|
"lang": resolved_lang,
|
||||||
|
"t": get_locale(resolved_lang),
|
||||||
|
"ack_version": ACKNOWLEDGEMENT_VERSION,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# Stamp the language cookie if the user used the on-page pill so
|
||||||
|
# subsequent visits land on the same translation without re-detecting.
|
||||||
|
if lang and lang in ACTIVE_PUBLIC_LANGS:
|
||||||
|
response.set_cookie(
|
||||||
|
_LANG_COOKIE, lang,
|
||||||
|
max_age=60 * 60 * 24 * 365, samesite="lax", httponly=False,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
|
|
@ -138,6 +176,9 @@ async def login_submit(
|
||||||
email: str = Form(...),
|
email: str = Form(...),
|
||||||
next: str | None = Form(default=None),
|
next: str | None = Form(default=None),
|
||||||
ref: str | None = Form(default=None),
|
ref: str | None = Form(default=None),
|
||||||
|
lang: str | None = Form(default=None),
|
||||||
|
acknowledged: str | None = Form(default=None),
|
||||||
|
ack_version: int | None = Form(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
|
|
@ -150,6 +191,27 @@ async def login_submit(
|
||||||
if ref_norm else None
|
if ref_norm else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Resolve language from the form (what the user actually saw) or
|
||||||
|
# fall back to detection. This is the language we'll record on the
|
||||||
|
# acknowledgement row if one gets written.
|
||||||
|
resolved_lang = _resolve_login_lang(request, lang)
|
||||||
|
t = get_locale(resolved_lang)
|
||||||
|
|
||||||
|
# Affirmative-acknowledgement check. Browsers only POST the checkbox
|
||||||
|
# name when ticked, so any truthy value here means accepted.
|
||||||
|
if not acknowledged:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "login.html",
|
||||||
|
{"next_path": _safe_next(next),
|
||||||
|
"error": str(t.auth.ack.error_required),
|
||||||
|
"email": email,
|
||||||
|
"ref": ref_norm if referrer else None,
|
||||||
|
"referrer_present": referrer is not None,
|
||||||
|
"lang": resolved_lang, "t": t,
|
||||||
|
"ack_version": ACKNOWLEDGEMENT_VERSION},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
# Track whether THIS request creates the user row (i.e. a referral
|
# Track whether THIS request creates the user row (i.e. a referral
|
||||||
# capture window). Cleanest way: probe for existence first.
|
# capture window). Cleanest way: probe for existence first.
|
||||||
from app.services.auth_service import get_user_by_email
|
from app.services.auth_service import get_user_by_email
|
||||||
|
|
@ -164,7 +226,9 @@ async def login_submit(
|
||||||
request, "login.html",
|
request, "login.html",
|
||||||
{"next_path": _safe_next(next), "error": str(e), "email": email,
|
{"next_path": _safe_next(next), "error": str(e), "email": email,
|
||||||
"ref": ref_norm if referrer else None,
|
"ref": ref_norm if referrer else None,
|
||||||
"referrer_present": referrer is not None},
|
"referrer_present": referrer is not None,
|
||||||
|
"lang": resolved_lang, "t": t,
|
||||||
|
"ack_version": ACKNOWLEDGEMENT_VERSION},
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -175,6 +239,15 @@ async def login_submit(
|
||||||
if was_new and referrer is not None:
|
if was_new and referrer is not None:
|
||||||
await referral_service.link_new_user(session, user, referrer)
|
await referral_service.link_new_user(session, user, referrer)
|
||||||
|
|
||||||
|
# Record the acknowledgement at the current version, idempotently.
|
||||||
|
# If the user has already accepted this version we skip — every
|
||||||
|
# sign-in needn't write a row, only every version bump does.
|
||||||
|
if not await has_acknowledged_current(session, user):
|
||||||
|
await record_acknowledgement(session, user, resolved_lang)
|
||||||
|
log.info("auth.acknowledgement_recorded",
|
||||||
|
user_id=user.id, version=ACKNOWLEDGEMENT_VERSION,
|
||||||
|
lang=resolved_lang)
|
||||||
|
|
||||||
# Issue OTP only if cooldown allows; if a fresh one was sent in the
|
# Issue OTP only if cooldown allows; if a fresh one was sent in the
|
||||||
# last 60s we just reuse the existing one (silently) to avoid
|
# last 60s we just reuse the existing one (silently) to avoid
|
||||||
# spamming the user's inbox on a refreshed form submit.
|
# spamming the user's inbox on a refreshed form submit.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import utcnow
|
from app.db import utcnow
|
||||||
from app.models import User
|
from app.legal import ACKNOWLEDGEMENT_VERSION
|
||||||
|
from app.models import User, UserAcknowledgement
|
||||||
|
|
||||||
|
|
||||||
class AuthError(Exception):
|
class AuthError(Exception):
|
||||||
|
|
@ -69,3 +70,35 @@ async def get_or_create_user(
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def has_acknowledged_current(
|
||||||
|
session: AsyncSession, user: User,
|
||||||
|
) -> bool:
|
||||||
|
"""True iff ``user`` has a ``user_acknowledgements`` row at the
|
||||||
|
currently-canonical ``ACKNOWLEDGEMENT_VERSION``. Composite-index hit
|
||||||
|
on (user_id, version); fast enough to run on every sign-in."""
|
||||||
|
row = (await session.execute(
|
||||||
|
select(UserAcknowledgement.id)
|
||||||
|
.where(UserAcknowledgement.user_id == user.id)
|
||||||
|
.where(UserAcknowledgement.version == ACKNOWLEDGEMENT_VERSION)
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def record_acknowledgement(
|
||||||
|
session: AsyncSession, user: User, lang: str,
|
||||||
|
) -> None:
|
||||||
|
"""Insert one ``user_acknowledgements`` row at the current version,
|
||||||
|
recording the language the user actually saw. The caller is
|
||||||
|
expected to gate on ``has_acknowledged_current`` first — this helper
|
||||||
|
does not de-duplicate so the audit trail can carry repeated
|
||||||
|
acceptances if a future caller wants them."""
|
||||||
|
session.add(UserAcknowledgement(
|
||||||
|
user_id=user.id,
|
||||||
|
version=ACKNOWLEDGEMENT_VERSION,
|
||||||
|
lang=lang,
|
||||||
|
accepted_at=utcnow(),
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="{{ lang or 'en' }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
@ -17,40 +17,110 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-shell">
|
<div class="auth-shell">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-card__brand">{{ BRAND_NAME }}</div>
|
<div class="auth-card__brand" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<div class="auth-card__hint">sign in with email</div>
|
<span>{{ BRAND_NAME }}</span>
|
||||||
|
<span class="auth-card__lang-switch" role="group"
|
||||||
|
aria-label="{{ (t and t.auth.ack.lang_switch_aria) or 'Language' }}"
|
||||||
|
style="font-size:11px; letter-spacing:0.04em;">
|
||||||
|
<a href="/login?lang=en{% if next_path and next_path != '/' %}&next={{ next_path }}{% endif %}{% if ref %}&ref={{ ref }}{% endif %}"
|
||||||
|
style="text-decoration:none; opacity:{{ '1' if lang == 'en' else '0.55' }};">EN</a>
|
||||||
|
<span style="opacity:0.4;">·</span>
|
||||||
|
<a href="/login?lang=it{% if next_path and next_path != '/' %}&next={{ next_path }}{% endif %}{% if ref %}&ref={{ ref }}{% endif %}"
|
||||||
|
style="text-decoration:none; opacity:{{ '1' if lang == 'it' else '0.55' }};">IT</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="auth-card__hint">
|
||||||
|
{% if lang == 'it' %}accedi via email{% else %}sign in with email{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if referrer_present %}
|
{% if referrer_present %}
|
||||||
<div class="auth-info auth-info--invited">
|
<div class="auth-info auth-info--invited">
|
||||||
<strong>You've been invited.</strong>
|
<strong>{% if lang == 'it' %}Sei stato invitato.{% else %}You've been invited.{% endif %}</strong>
|
||||||
|
{% if lang == 'it' %}
|
||||||
|
Quando ti abboni, tu e il tuo amico ricevete entrambi
|
||||||
|
<strong>50% di sconto per 3 mesi</strong>. Iscriviti qui sotto per attivarlo.
|
||||||
|
{% else %}
|
||||||
When you subscribe, you and your friend both get
|
When you subscribe, you and your friend both get
|
||||||
<strong>50% off for 3 months</strong>. Sign up below to lock it in.
|
<strong>50% off for 3 months</strong>. Sign up below to lock it in.
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="auth-card__lede">
|
<p class="auth-card__lede">
|
||||||
|
{% if lang == 'it' %}
|
||||||
|
Inserisci la tua email e ti invieremo un codice di 6 cifre. Niente password.
|
||||||
|
I nuovi visitatori creano un account; chi torna fa l'accesso.
|
||||||
|
{% else %}
|
||||||
Enter your email and we'll send you a 6-digit code. No password.
|
Enter your email and we'll send you a 6-digit code. No password.
|
||||||
First-time visitors get an account; returning visitors get a sign-in.
|
First-time visitors get an account; returning visitors get a sign-in.
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
|
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/login" autocomplete="on">
|
<form method="post" action="/login" autocomplete="on">
|
||||||
<input type="hidden" name="next" value="{{ next_path or '/' }}">
|
<input type="hidden" name="next" value="{{ next_path or '/' }}">
|
||||||
|
<input type="hidden" name="lang" value="{{ lang or 'en' }}">
|
||||||
|
<input type="hidden" name="ack_version" value="{{ ack_version }}">
|
||||||
{% if ref %}<input type="hidden" name="ref" value="{{ ref }}">{% endif %}
|
{% if ref %}<input type="hidden" name="ref" value="{{ ref }}">{% endif %}
|
||||||
<label>Email
|
<label>Email
|
||||||
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Send code</button>
|
|
||||||
|
{# Affirmative un-pre-ticked acknowledgement. The substance is
|
||||||
|
locale-driven (auth.ack.*); the checkbox is required client-side
|
||||||
|
AND server-side. Replaces a passive "by continuing you agree…"
|
||||||
|
paragraph because an active acceptance carries more weight. #}
|
||||||
|
{% if t %}
|
||||||
|
<div class="auth-ack" style="margin-top:18px; padding:14px 16px;
|
||||||
|
border:1px solid var(--border, #ddd); border-radius:6px;
|
||||||
|
font-size:12.5px; line-height:1.55;">
|
||||||
|
<div style="font-weight:600; margin-bottom:8px;">{{ t.auth.ack.heading }}</div>
|
||||||
|
<ul style="margin:0 0 12px 18px; padding:0;">
|
||||||
|
{% for item in t.auth.ack.items %}
|
||||||
|
<li style="margin-bottom:6px;">{{ item }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<label style="display:flex; gap:8px; align-items:flex-start; cursor:pointer;">
|
||||||
|
<input type="checkbox" name="acknowledged" id="ack-box"
|
||||||
|
value="on" required style="margin-top:3px;">
|
||||||
|
<span>{{ t.auth.ack.checkbox_label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="submit" id="ack-submit" disabled
|
||||||
|
style="margin-top:14px;">
|
||||||
|
{% if lang == 'it' %}Invia codice{% else %}Send code{% endif %}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="auth-card__legal" style="margin-top:18px; font-size:11px; color: var(--muted); line-height:1.6;">
|
<p class="auth-card__legal" style="margin-top:18px; font-size:11px; color: var(--muted); line-height:1.6;">
|
||||||
By signing in you agree to our
|
{% if lang == 'it' %}
|
||||||
<a href="/terms">Terms</a> and
|
Vedi i nostri
|
||||||
<a href="/privacy">Privacy notice</a>, and confirm you’ve read
|
<a href="/terms">Termini</a>, l’<a href="/privacy">Informativa privacy</a>
|
||||||
the <a href="/disclaimer">financial disclaimer</a>.
|
e il <a href="/disclaimer">disclaimer finanziario</a>.
|
||||||
|
{% else %}
|
||||||
|
See our
|
||||||
|
<a href="/terms">Terms</a>,
|
||||||
|
<a href="/privacy">Privacy notice</a>, and
|
||||||
|
<a href="/disclaimer">financial disclaimer</a>.
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// Tiny UX polish: disable the submit until the box is ticked. The
|
||||||
|
// server-side validation is what protects us — this is just so the
|
||||||
|
// user can't burn a click and immediately see an error.
|
||||||
|
(function () {
|
||||||
|
var box = document.getElementById('ack-box');
|
||||||
|
var btn = document.getElementById('ack-submit');
|
||||||
|
if (!box || !btn) return;
|
||||||
|
function sync() { btn.disabled = !box.checked; }
|
||||||
|
box.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
388
tests/test_signup_acknowledgement.py
Normal file
388
tests/test_signup_acknowledgement.py
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
"""Sign-up acknowledgement: the affirmative checkbox at /login.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- POST /login without the box ticked → 400, form re-rendered with the error.
|
||||||
|
- New email with box ticked → User row + UserAcknowledgement row at the
|
||||||
|
current version, in the language the user actually saw.
|
||||||
|
- Existing user with a current-version ack row → POST succeeds, no duplicate.
|
||||||
|
- Existing user with only an older-version ack row → new row at current.
|
||||||
|
- has_acknowledged_current() unit tests.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
def _build(tmp_path):
|
||||||
|
"""Spin up a fresh app + sqlite DB + tables. Returns (TestClient, factory).
|
||||||
|
|
||||||
|
Patches otp_service and email send into no-ops so POST /login can complete
|
||||||
|
without hitting SMTP. The acknowledgement is captured during POST /login
|
||||||
|
(before OTP), so /verify never needs to be exercised here. Static files
|
||||||
|
are mounted because the rejection path re-renders login.html which
|
||||||
|
references ``url_for('static', ...)``."""
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app import db as db_mod
|
||||||
|
from app.db import Base
|
||||||
|
import app.models # noqa: F401 — registers tables
|
||||||
|
from app.routers import auth as auth_router
|
||||||
|
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/ack.db")
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
db_mod._engine = engine
|
||||||
|
db_mod._session_factory = factory
|
||||||
|
|
||||||
|
async def _create_all():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
asyncio.run(_create_all())
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(auth_router.router)
|
||||||
|
static_dir = Path(__file__).resolve().parent.parent / "app" / "static"
|
||||||
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
return TestClient(app), factory
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_otp_email(monkeypatch):
|
||||||
|
"""Stub OTP issuance and email send so POST /login can run end-to-end
|
||||||
|
without a Redis-backed OTP service or real SMTP."""
|
||||||
|
from app.services import otp_service
|
||||||
|
from app.routers import auth as auth_router
|
||||||
|
|
||||||
|
async def _allowed(*_a, **_kw):
|
||||||
|
return (True, 0)
|
||||||
|
|
||||||
|
async def _issue(*_a, **_kw):
|
||||||
|
return "123456"
|
||||||
|
|
||||||
|
async def _send_ok(*_a, **_kw):
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(otp_service, "can_request_new", _allowed)
|
||||||
|
monkeypatch.setattr(otp_service, "issue", _issue)
|
||||||
|
# _issue_and_send_otp lives on the router and wraps the email send;
|
||||||
|
# easier to stub the whole helper than to thread through email_service.
|
||||||
|
monkeypatch.setattr(auth_router, "_issue_and_send_otp", _send_ok)
|
||||||
|
|
||||||
|
|
||||||
|
async def _count_acks(factory, user_id: int, version: int | None = None) -> int:
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from app.models import UserAcknowledgement
|
||||||
|
async with factory() as s:
|
||||||
|
q = select(func.count()).select_from(UserAcknowledgement).where(
|
||||||
|
UserAcknowledgement.user_id == user_id,
|
||||||
|
)
|
||||||
|
if version is not None:
|
||||||
|
q = q.where(UserAcknowledgement.version == version)
|
||||||
|
return (await s.execute(q)).scalar() or 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /login validation: missing-checkbox path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_login_rejects_when_acknowledged_unchecked(tmp_path, monkeypatch):
|
||||||
|
_patch_otp_email(monkeypatch)
|
||||||
|
client, _ = _build(tmp_path)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"email": "alice@example.com", "next": "/", "lang": "en"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
# The localised error message goes back in the rendered template.
|
||||||
|
assert "tick the box" in r.text.lower() or "confirm" in r.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_login_rejection_preserves_email(tmp_path, monkeypatch):
|
||||||
|
_patch_otp_email(monkeypatch)
|
||||||
|
client, _ = _build(tmp_path)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"email": "alice@example.com", "next": "/", "lang": "en"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "alice@example.com" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_login_localises_error_in_italian(tmp_path, monkeypatch):
|
||||||
|
_patch_otp_email(monkeypatch)
|
||||||
|
client, _ = _build(tmp_path)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"email": "anna@example.com", "next": "/", "lang": "it"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
# IT error: "Spunta la casella per confermare prima di continuare."
|
||||||
|
assert "spunta la casella" in r.text.lower() or "confermare" in r.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Successful POST /login: writes User + UserAcknowledgement
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_signup_writes_acknowledgement_row(tmp_path, monkeypatch):
|
||||||
|
_patch_otp_email(monkeypatch)
|
||||||
|
client, factory = _build(tmp_path)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/login",
|
||||||
|
data={
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"next": "/",
|
||||||
|
"lang": "en",
|
||||||
|
"acknowledged": "on",
|
||||||
|
"ack_version": "1",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
# 303 → /verify
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert r.headers["location"].startswith("/verify")
|
||||||
|
|
||||||
|
# Find the new user and assert exactly one acknowledgement row at v1.
|
||||||
|
from app.models import User, UserAcknowledgement
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
async with factory() as s:
|
||||||
|
user = (await s.execute(
|
||||||
|
select(User).where(User.email == "alice@example.com")
|
||||||
|
)).scalar_one()
|
||||||
|
rows = (await s.execute(
|
||||||
|
select(UserAcknowledgement).where(
|
||||||
|
UserAcknowledgement.user_id == user.id,
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
return user, rows
|
||||||
|
|
||||||
|
user, rows = asyncio.run(_check())
|
||||||
|
assert len(rows) == 1
|
||||||
|
ack = rows[0]
|
||||||
|
assert ack.version == 1
|
||||||
|
assert ack.lang == "en"
|
||||||
|
assert ack.accepted_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_acknowledgement_records_displayed_language(tmp_path, monkeypatch):
|
||||||
|
_patch_otp_email(monkeypatch)
|
||||||
|
client, factory = _build(tmp_path)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/login",
|
||||||
|
data={
|
||||||
|
"email": "anna@example.it",
|
||||||
|
"next": "/",
|
||||||
|
"lang": "it",
|
||||||
|
"acknowledged": "on",
|
||||||
|
"ack_version": "1",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
from app.models import User, UserAcknowledgement
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
async def _check():
|
||||||
|
async with factory() as s:
|
||||||
|
user = (await s.execute(
|
||||||
|
select(User).where(User.email == "anna@example.it")
|
||||||
|
)).scalar_one()
|
||||||
|
ack = (await s.execute(
|
||||||
|
select(UserAcknowledgement).where(
|
||||||
|
UserAcknowledgement.user_id == user.id,
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
return ack.lang
|
||||||
|
|
||||||
|
assert asyncio.run(_check()) == "it"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Idempotency: existing user already at current version → no dup row
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_user_current_version_no_duplicate(tmp_path, monkeypatch):
|
||||||
|
_patch_otp_email(monkeypatch)
|
||||||
|
client, factory = _build(tmp_path)
|
||||||
|
|
||||||
|
# Pre-seed: User + one acknowledgement at the current version.
|
||||||
|
async def _seed():
|
||||||
|
from app.models import User, UserAcknowledgement
|
||||||
|
from app.legal import ACKNOWLEDGEMENT_VERSION
|
||||||
|
from app.db import utcnow
|
||||||
|
|
||||||
|
async with factory() as s:
|
||||||
|
u = User(email="repeat@example.com", tier="free",
|
||||||
|
settings_json={}, created_at=utcnow())
|
||||||
|
s.add(u)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(u)
|
||||||
|
s.add(UserAcknowledgement(
|
||||||
|
user_id=u.id,
|
||||||
|
version=ACKNOWLEDGEMENT_VERSION,
|
||||||
|
lang="en",
|
||||||
|
accepted_at=utcnow(),
|
||||||
|
))
|
||||||
|
await s.commit()
|
||||||
|
return u.id
|
||||||
|
|
||||||
|
user_id = asyncio.run(_seed())
|
||||||
|
before = asyncio.run(_count_acks(factory, user_id))
|
||||||
|
assert before == 1
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/login",
|
||||||
|
data={
|
||||||
|
"email": "repeat@example.com",
|
||||||
|
"next": "/",
|
||||||
|
"lang": "en",
|
||||||
|
"acknowledged": "on",
|
||||||
|
"ack_version": "1",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
after = asyncio.run(_count_acks(factory, user_id))
|
||||||
|
assert after == 1, "must not write a duplicate row when user already at current version"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Version bump: existing user only at older version → new current-version row
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_user_older_version_writes_new_current_row(tmp_path, monkeypatch):
|
||||||
|
_patch_otp_email(monkeypatch)
|
||||||
|
client, factory = _build(tmp_path)
|
||||||
|
|
||||||
|
# Pre-seed a user with an OLD-version acknowledgement (version=0).
|
||||||
|
# The current version constant is 1 → this user is "stale" and should
|
||||||
|
# be prompted again.
|
||||||
|
async def _seed():
|
||||||
|
from app.models import User, UserAcknowledgement
|
||||||
|
from app.db import utcnow
|
||||||
|
|
||||||
|
async with factory() as s:
|
||||||
|
u = User(email="bump@example.com", tier="free",
|
||||||
|
settings_json={}, created_at=utcnow())
|
||||||
|
s.add(u)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(u)
|
||||||
|
s.add(UserAcknowledgement(
|
||||||
|
user_id=u.id, version=0, lang="en", accepted_at=utcnow(),
|
||||||
|
))
|
||||||
|
await s.commit()
|
||||||
|
return u.id
|
||||||
|
|
||||||
|
user_id = asyncio.run(_seed())
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/login",
|
||||||
|
data={
|
||||||
|
"email": "bump@example.com",
|
||||||
|
"next": "/",
|
||||||
|
"lang": "en",
|
||||||
|
"acknowledged": "on",
|
||||||
|
"ack_version": "1",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
# Total rows: 1 old + 1 new = 2. Current-version rows: exactly 1.
|
||||||
|
total = asyncio.run(_count_acks(factory, user_id))
|
||||||
|
current = asyncio.run(_count_acks(factory, user_id, version=1))
|
||||||
|
assert total == 2
|
||||||
|
assert current == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# has_acknowledged_current() — unit-ish, no HTTP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_acknowledged_current_no_row(tmp_path):
|
||||||
|
_, factory = _build(tmp_path)
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
from app.models import User
|
||||||
|
from app.services.auth_service import has_acknowledged_current
|
||||||
|
from app.db import utcnow
|
||||||
|
|
||||||
|
async with factory() as s:
|
||||||
|
u = User(email="empty@example.com", tier="free",
|
||||||
|
settings_json={}, created_at=utcnow())
|
||||||
|
s.add(u)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(u)
|
||||||
|
return await has_acknowledged_current(s, u)
|
||||||
|
|
||||||
|
assert asyncio.run(_go()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_acknowledged_current_only_old(tmp_path):
|
||||||
|
_, factory = _build(tmp_path)
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
from app.models import User, UserAcknowledgement
|
||||||
|
from app.services.auth_service import has_acknowledged_current
|
||||||
|
from app.db import utcnow
|
||||||
|
|
||||||
|
async with factory() as s:
|
||||||
|
u = User(email="oldonly@example.com", tier="free",
|
||||||
|
settings_json={}, created_at=utcnow())
|
||||||
|
s.add(u)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(u)
|
||||||
|
s.add(UserAcknowledgement(
|
||||||
|
user_id=u.id, version=0, lang="en", accepted_at=utcnow(),
|
||||||
|
))
|
||||||
|
await s.commit()
|
||||||
|
return await has_acknowledged_current(s, u)
|
||||||
|
|
||||||
|
assert asyncio.run(_go()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_acknowledged_current_at_current(tmp_path):
|
||||||
|
_, factory = _build(tmp_path)
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
from app.models import User, UserAcknowledgement
|
||||||
|
from app.services.auth_service import has_acknowledged_current
|
||||||
|
from app.legal import ACKNOWLEDGEMENT_VERSION
|
||||||
|
from app.db import utcnow
|
||||||
|
|
||||||
|
async with factory() as s:
|
||||||
|
u = User(email="atcurrent@example.com", tier="free",
|
||||||
|
settings_json={}, created_at=utcnow())
|
||||||
|
s.add(u)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(u)
|
||||||
|
s.add(UserAcknowledgement(
|
||||||
|
user_id=u.id,
|
||||||
|
version=ACKNOWLEDGEMENT_VERSION,
|
||||||
|
lang="en",
|
||||||
|
accepted_at=utcnow(),
|
||||||
|
))
|
||||||
|
await s.commit()
|
||||||
|
return await has_acknowledged_current(s, u)
|
||||||
|
|
||||||
|
assert asyncio.run(_go()) is True
|
||||||
Loading…
Add table
Add a link
Reference in a new issue