From 8d97cb8b5e8d60079b5a956a49d39f59b58130d8 Mon Sep 17 00:00:00 2001 From: Andreas Date: Wed, 17 Apr 2024 23:07:11 +0200 Subject: [PATCH] Command and routes for invite codes (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ➕ Add command for invite codes generation * ➕ Add basic repo functions for invite codes * ❌ remove default value, add is_used property * ➕ Add basic routes for invite codes * ➕ Include invite router and update docs * 📜 Update docs --- backend/README.md | 25 ++++--- .../commands/create_invite_codes.py | 17 +++++ backend/src/appointment/database/models.py | 33 +++++++++ backend/src/appointment/database/repo.py | 71 ++++++++++++++++++- backend/src/appointment/database/schemas.py | 13 ++++ .../src/appointment/exceptions/validation.py | 18 +++++ backend/src/appointment/l10n/de/main.ftl | 1 + backend/src/appointment/l10n/en/main.ftl | 1 + backend/src/appointment/main.py | 2 + ..._1241-fadd0d1ef438_create_invites_table.py | 39 ++++++++++ backend/src/appointment/routes/commands.py | 7 +- backend/src/appointment/routes/invite.py | 41 +++++++++++ 12 files changed, 256 insertions(+), 12 deletions(-) create mode 100644 backend/src/appointment/commands/create_invite_codes.py create mode 100644 backend/src/appointment/migrations/versions/2024_04_16_1241-fadd0d1ef438_create_invites_table.py create mode 100644 backend/src/appointment/routes/invite.py diff --git a/backend/README.md b/backend/README.md index 4f1b28d2d..bfb291f4e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Thunderbird Appointment Backend -This is the backend component of Thunderbird Appointment written in Python using FastAPI, SQLAlchemy, and pytest. +This is the backend component of Thunderbird Appointment written in Python using FastAPI, SQLAlchemy, and pytest. ## Installation / Running @@ -20,24 +20,29 @@ You will want to ensure any variable ending with `_SECRET` has a secret value as ### Authentication -This project is deployed with Mozilla Accounts (known as fxa in the code.) Since Mozilla Accounts is for internal use you will need to use password authentication. Note: password authentication does not currently have a registration flow. +This project is deployed with Mozilla Accounts (known as fxa in the code.) Since Mozilla Accounts is for internal use you will need to use password authentication. Note: password authentication does not currently have a registration flow. ## Commands Backend has a light selection of cli commands available to be run inside a container. -``` +```bash run-command main --help - Usage: run-command main [OPTIONS] COMMAND [ARGS]... - +``` + +```plain + Usage: run-command main [OPTIONS] COMMAND [ARGS]... + ╭─ Options ──────────────────────────────────────────────────────╮ -│ --help Show this message and exit. │ +│ --help Show this message and exit. │ ╰────────────────────────────────────────────────────────────────╯ ╭─ Commands ─────────────────────────────────────────────────────╮ -│ download-legal │ -│ update-db │ +│ download-legal │ +│ update-db │ +│ create-invite-codes │ ╰────────────────────────────────────────────────────────────────╯ ``` -* Download-legal is an internal command to process privacy policy and terms of service files that will be served by the frontend. -* Update-db runs on docker container entry, and ensures the latest db migration has run, or if it's a new db then to kickstart that. +* `download-legal` is an internal command to process privacy policy and terms of service files that will be served by the frontend. +* `update-db` runs on docker container entry, and ensures the latest db migration has run, or if it's a new db then to kickstart that. +* `create-invite-codes n` is an internal command to create invite codes which can be used for user registrations. The `n` argument is an integer that specifies the amount of codes to be generated. diff --git a/backend/src/appointment/commands/create_invite_codes.py b/backend/src/appointment/commands/create_invite_codes.py new file mode 100644 index 000000000..38a3dd0d6 --- /dev/null +++ b/backend/src/appointment/commands/create_invite_codes.py @@ -0,0 +1,17 @@ +import os + +from ..database import repo +from ..dependencies.database import get_engine_and_session + + +def run(n: int): + print(f"Generating {n} new invite codes...") + + _, session = get_engine_and_session() + db = session() + + codes = repo.generate_invite_codes(db, n) + + db.close() + + print(f"Successfull added {len(codes)} shiny new invite codes to the database.") diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index c4e29566b..d8812167b 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -75,6 +75,11 @@ class MeetingLinkProviderType(enum.StrEnum): google_meet = 'google_meet' +class InviteStatus(enum.Enum): + active = 1 # The code is still valid. It may be already used or is still to be used + revoked = 2 # The code is no longer valid and cannot be used for sign up anymore + + @as_declarative() class Base: """Base model, contains anything we want to be on every model.""" @@ -107,6 +112,7 @@ class Subscriber(Base): calendars = relationship("Calendar", cascade="all,delete", back_populates="owner") slots = relationship("Slot", cascade="all,delete", back_populates="subscriber") external_connections = relationship("ExternalConnections", cascade="all,delete", back_populates="owner") + invite: "Invite" = relationship("Invite", back_populates="subscriber") def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConnections': """Retrieves the first found external connection by type or returns None if not found""" @@ -266,3 +272,30 @@ class ExternalConnections(Base): type_id = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) token = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False) owner = relationship("Subscriber", back_populates="external_connections") + + +class Invite(Base): + """This table holds all invite codes for code based sign-ups.""" + __tablename__ = "invites" + + id = Column(Integer, primary_key=True, index=True) + subscriber_id = Column(Integer, ForeignKey("subscribers.id")) + code = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) + status = Column(Enum(InviteStatus), index=True) + + subscriber = relationship("Subscriber", back_populates="invite") + + @property + def is_used(self) -> bool: + """True if the invite code is assigned to a subscriber""" + return self.subscriber_id is not None + + @property + def is_revoked(self) -> bool: + """True if the invite code is revoked""" + return self.status == InviteStatus.revoked + + @property + def is_available(self) -> bool: + """True if the invite code is not assigned nor revoked""" + return self.subscriber_id is None and self.status == InviteStatus.active diff --git a/backend/src/appointment/database/repo.py b/backend/src/appointment/database/repo.py index 5eb6128a2..28285f0e1 100644 --- a/backend/src/appointment/database/repo.py +++ b/backend/src/appointment/database/repo.py @@ -4,6 +4,8 @@ """ import os import re +import uuid + from datetime import timedelta, datetime from fastapi import HTTPException @@ -557,7 +559,74 @@ def schedule_has_slot(db: Session, schedule_id: int, slot_id: int): return db_slot and db_slot.schedule_id == schedule_id -"""External Connections repository functions""" +"""INVITES repository functions +""" + + +def get_invite_by_code(db: Session, code: str): + """retrieve invite by code""" + return db.query(models.Invite).filter(models.Invite.code == code).first() + + +def generate_invite_codes(db: Session, n: int): + """generate n invite codes and return the list of created invite objects""" + codes = [str(uuid.uuid4()) for _ in range(n)] + db_invites = [] + for code in codes: + invite = schemas.Invite(code=code) + db_invite = models.Invite(**invite.dict()) + db.add(db_invite) + db.commit() + db_invites.append(db_invite) + return db_invites + + +def invite_code_exists(db: Session, code: str): + """true if invite code exists""" + return True if get_invite_by_code(db, code) is not None else False + + +def invite_code_is_used(db: Session, code: str): + """true if invite code is assigned to a user""" + db_invite = get_invite_by_code(db, code) + return db_invite.is_used + + +def invite_code_is_revoked(db: Session, code: str): + """true if invite code is revoked""" + db_invite = get_invite_by_code(db, code) + return db_invite.is_revoked + + +def invite_code_is_available(db: Session, code: str): + """true if invite code exists and can still be used""" + db_invite = get_invite_by_code(db, code) + return db_invite and db_invite.is_available + + +def use_invite_code(db: Session, code: str, subscriber_id: int): + """assign given subscriber to an invite""" + db_invite = get_invite_by_code(db, code) + if db_invite and db_invite.is_available: + db_invite.subscriber_id = subscriber_id + db.commit() + db.refresh(db_invite) + return True + else: + return False + + +def revoke_invite_code(db: Session, code: str): + """set existing invite code status to revoked""" + db_invite = get_invite_by_code(db, code) + db_invite.status = models.InviteStatus.revoked + db.commit() + db.refresh(db_invite) + return True + + +"""External Connections repository functions +""" def create_subscriber_external_connection(db: Session, external_connection: ExternalConnection): diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 3efd10cf2..a30bc4371 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -19,6 +19,7 @@ SubscriberLevel, ExternalConnectionType, MeetingLinkProviderType, + InviteStatus, ) from .. import utils @@ -250,6 +251,18 @@ class Config: from_attributes = True +""" INVITE model schemas +""" + + +class Invite(BaseModel): + subscriber_id: int | None = None + code: str + status: InviteStatus = InviteStatus.active + time_created: datetime | None = None + time_updated: datetime | None = None + + """ other schemas used for requests or data migration """ diff --git a/backend/src/appointment/exceptions/validation.py b/backend/src/appointment/exceptions/validation.py index eeafa0996..05ac99841 100644 --- a/backend/src/appointment/exceptions/validation.py +++ b/backend/src/appointment/exceptions/validation.py @@ -169,3 +169,21 @@ class EventCouldNotBeAccepted(APIException): def get_msg(self): return l10n('event-could-not-be-accepted') + + +class InviteCodeNotFoundException(APIException): + """Raise when the invite code is not found during route validation""" + id_code = 'INVITE_CODE_NOT_FOUND' + status_code = 404 + + def get_msg(self): + return l10n('invite-code-not-valid') + + +class InviteCodeNotAvailableException(APIException): + """Raise when the invite code is not available anymore during route validation""" + id_code = 'INVITE_CODE_NOT_AVAILABLE' + status_code = 403 + + def get_msg(self): + return l10n('invite-code-not-valid') diff --git a/backend/src/appointment/l10n/de/main.ftl b/backend/src/appointment/l10n/de/main.ftl index 5c5a671ee..25f3adaf7 100644 --- a/backend/src/appointment/l10n/de/main.ftl +++ b/backend/src/appointment/l10n/de/main.ftl @@ -32,6 +32,7 @@ calendar-not-active = Der Kalender ist nicht verbunden. slot-not-found = Es gibt keine freien Zeitfenster zu buchen. slot-already-taken = Das gewählte Zeitfenster ist nicht mehr verfügbar. Bitte erneut versuchen. slot-invalid-email = Die angegebene E-Mail-Adresse war nicht gültig. Bitte erneut versuchen. +invite-code-not-valid = Der Einladungscode ist leider nicht gültig. schedule-not-active = Der Zeitplan wurde abgeschaltet. Bitte für weitere Informationen den Eigentümer des Zeitplans kontaktieren. diff --git a/backend/src/appointment/l10n/en/main.ftl b/backend/src/appointment/l10n/en/main.ftl index d8c0b56e4..a0967a24c 100644 --- a/backend/src/appointment/l10n/en/main.ftl +++ b/backend/src/appointment/l10n/en/main.ftl @@ -32,6 +32,7 @@ calendar-not-active = The calendar connection is not active. slot-not-found = There are no available time slots to book. slot-already-taken = The time slot you have selected is no longer available. Please try again. slot-invalid-email = The email you have provided was not valid. Please try again. +invite-code-not-valid = The invite code you used is not valid. schedule-not-active = The schedule has been turned off. Please contact the schedule owner for more information. diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index 94903bcb9..0cbd0c0c8 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -102,6 +102,7 @@ def server(): from .routes import account from .routes import google from .routes import schedule + from .routes import invite from .routes import zoom from .routes import webhooks @@ -169,6 +170,7 @@ async def catch_google_refresh_errors(request, exc): app.include_router(account.router, prefix="/account") app.include_router(google.router, prefix="/google") app.include_router(schedule.router, prefix="/schedule") + app.include_router(invite.router, prefix="/invite") app.include_router(webhooks.router, prefix="/webhooks") if os.getenv("ZOOM_API_ENABLED"): app.include_router(zoom.router, prefix="/zoom") diff --git a/backend/src/appointment/migrations/versions/2024_04_16_1241-fadd0d1ef438_create_invites_table.py b/backend/src/appointment/migrations/versions/2024_04_16_1241-fadd0d1ef438_create_invites_table.py new file mode 100644 index 000000000..2362a3512 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_04_16_1241-fadd0d1ef438_create_invites_table.py @@ -0,0 +1,39 @@ +"""create invites table + +Revision ID: fadd0d1ef438 +Revises: c5b9fc31b555 +Create Date: 2024-04-16 12:41:53.550102 + +""" +from alembic import op +import sqlalchemy as sa +from database.models import InviteStatus +from sqlalchemy import func, ForeignKey +from sqlalchemy_utils import StringEncryptedType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine + +# revision identifiers, used by Alembic. +revision = 'fadd0d1ef438' +down_revision = 'c5b9fc31b555' +branch_labels = None +depends_on = None + + +def secret(): + return os.getenv("DB_SECRET") + + +def upgrade() -> None: + op.create_table( + 'invites', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('subscriber_id', sa.Integer, ForeignKey("subscribers.id")), + sa.Column('code', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), index=False), + sa.Column('status', sa.Enum(InviteStatus), index=True), + sa.Column('time_created', sa.DateTime, server_default=func.now()), + sa.Column('time_updated', sa.DateTime, server_default=func.now()), + ) + + +def downgrade() -> None: + op.drop_table('invites') diff --git a/backend/src/appointment/routes/commands.py b/backend/src/appointment/routes/commands.py index b68103020..2c87e4585 100644 --- a/backend/src/appointment/routes/commands.py +++ b/backend/src/appointment/routes/commands.py @@ -3,7 +3,7 @@ import os import typer -from ..commands import update_db, download_legal +from ..commands import update_db, download_legal, create_invite_codes router = typer.Typer() @@ -33,3 +33,8 @@ def update_database(): @router.command('download-legal') def download_legal_docs(): download_legal.run() + + +@router.command('create-invite-codes') +def create_app_invite_codes(n: int): + create_invite_codes.run(n) diff --git a/backend/src/appointment/routes/invite.py b/backend/src/appointment/routes/invite.py new file mode 100644 index 000000000..eae73447e --- /dev/null +++ b/backend/src/appointment/routes/invite.py @@ -0,0 +1,41 @@ + +from fastapi import APIRouter, Depends + +from sqlalchemy.orm import Session + +from ..database import repo, schemas, models +from ..dependencies.auth import get_subscriber, get_subscriber_from_signed_url +from ..dependencies.database import get_db + +from ..exceptions import validation + +router = APIRouter() + + +@router.post("/generate/{n}", response_model=list[schemas.Invite]) +def generate_invite_codes(n: int, db: Session = Depends(get_db)): + """endpoint to generate n invite codes""" + return repo.generate_invite_codes(db, n) + + +@router.put("/redeem/{code}") +def use_invite_code(code: str, db: Session = Depends(get_db)): + """endpoint to create a new subscriber and update the corresponding invite""" + if not repo.invite_code_exists(db, code): + raise validation.InviteCodeNotFoundException() + if not repo.invite_code_is_available(db, code): + raise validation.InviteCodeNotAvailableException() + # TODO: get email from admin panel + email = 'placeholder@mozilla.org' + subscriber = repo.create_subscriber(db, schemas.SubscriberBase(email=email, username=email)) + return repo.use_invite_code(db, code, subscriber.id) + + +@router.put("/revoke/{code}") +def use_invite_code(code: str, db: Session = Depends(get_db)): + """endpoint to revoke a given invite code and mark in unavailable""" + if not repo.invite_code_exists(db, code): + raise validation.InviteCodeNotFoundException() + if not repo.invite_code_is_available(db, code): + raise validation.InviteCodeNotAvailableException() + return repo.revoke_invite_code(db, code)