Skip to content

Commit

Permalink
Command and routes for invite codes (#360)
Browse files Browse the repository at this point in the history
* ➕ 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
  • Loading branch information
devmount authored Apr 17, 2024
1 parent 702ba08 commit 8d97cb8
Show file tree
Hide file tree
Showing 12 changed files with 256 additions and 12 deletions.
25 changes: 15 additions & 10 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
17 changes: 17 additions & 0 deletions backend/src/appointment/commands/create_invite_codes.py
Original file line number Diff line number Diff line change
@@ -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.")
33 changes: 33 additions & 0 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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
71 changes: 70 additions & 1 deletion backend/src/appointment/database/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"""
import os
import re
import uuid

from datetime import timedelta, datetime

from fastapi import HTTPException
Expand Down Expand Up @@ -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):
Expand Down
13 changes: 13 additions & 0 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
SubscriberLevel,
ExternalConnectionType,
MeetingLinkProviderType,
InviteStatus,
)
from .. import utils

Expand Down Expand Up @@ -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
"""

Expand Down
18 changes: 18 additions & 0 deletions backend/src/appointment/exceptions/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
1 change: 1 addition & 0 deletions backend/src/appointment/l10n/de/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions backend/src/appointment/l10n/en/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions backend/src/appointment/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
7 changes: 6 additions & 1 deletion backend/src/appointment/routes/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
41 changes: 41 additions & 0 deletions backend/src/appointment/routes/invite.py
Original file line number Diff line number Diff line change
@@ -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 = '[email protected]'
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)

0 comments on commit 8d97cb8

Please sign in to comment.