Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Desdeo2 join link endpoints #165

Open
wants to merge 9 commits into
base: desdeo2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion desdeo/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from desdeo.api.routers import NIMBUS, NAUTILUS_navigator, UserAuth, problems, test, NAUTILUS
from desdeo.api.routers import NIMBUS, NAUTILUS_navigator, UserAuth, problems, test, NAUTILUS, Admin
from desdeo.api.config import WebUIConfig

app = FastAPI(
Expand All @@ -19,6 +19,7 @@
app.include_router(problems.router)
app.include_router(NAUTILUS_navigator.router)
app.include_router(NAUTILUS.router)
app.include_router(Admin.router)

origins = WebUIConfig.cors_origins

Expand Down
11 changes: 11 additions & 0 deletions desdeo/api/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,14 @@ class Log(Base):
action: Mapped[str] = mapped_column(nullable=False)
value = mapped_column(JSON, nullable=False)
timestamp: Mapped[str] = mapped_column(nullable=False)

class Invite(Base):
"""A model to store dm-specific invitations to problems."""

__tablename__ = "invite"
id: Mapped[int] = mapped_column(primary_key=True, unique=True, index = True)
inviter= mapped_column(Integer,ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
invitee= mapped_column(Integer,ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
problem_id: Mapped[int] = mapped_column(Integer, ForeignKey("problem.id"), nullable=False)
code: Mapped[str] = mapped_column(nullable=False)
date_created: Mapped[str] = mapped_column(nullable=False, default=str(datetime.now()))
61 changes: 61 additions & 0 deletions desdeo/api/routers/Admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Router for admin actions."""
import time
from typing import Annotated
from hashlib import blake2b

from fastapi import APIRouter, Depends

from desdeo.api.db_models import Invite
from desdeo.api.schema import User
from desdeo.api.routers.UserAuth import get_current_user
from desdeo.api.security.http import InviteForm
from desdeo.api.utils.database import (
database_dependency,
)

router = APIRouter()

def create_invite_code(data: dict) -> str:
"""Create unique invite code.

Args:
data (dict): The data to encode in the code.

Returns:
str: Invite code.
"""

data = data.copy()
code = blake2b(str.encode(str(data)), digest_size=8).hexdigest() + str(time.time())
return code

@router.post("/invite")
async def createInvite(
form_data: Annotated[InviteForm, Depends()],
db: Annotated[database_dependency, Depends()],
user: Annotated[User, Depends(get_current_user)],
) -> dict:
"""Create an invitation with unique code.

Args:
form_data (Annotated[InviteForm, Depends()]): The form data for the invitation.
db (Annotated[database_dependency, Depends()]): The database session.
user (Annotated[User, Depends(get_current_user)]): The user details

Returns:
dict: Dictionary with invitation code
"""

code = create_invite_code({"invitee": form_data.invitee, "problem_id": form_data.problem_id})
new_invite = Invite(
inviter = user.index,
invitee = form_data.invitee,
problem_id = form_data.problem_id,
code = code
)

await db.add(new_invite)
await db.commit()

return { "code": code }

43 changes: 42 additions & 1 deletion desdeo/api/routers/UserAuth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@

from desdeo.api import AuthConfig
from desdeo.api.db import get_db
from desdeo.api.db_models import User as UserModel
from desdeo.api.db_models import User as UserModel, Invite
from desdeo.api.schema import User
from desdeo.api.utils.database import (
database_dependency,
select,
filter_by
)

router = APIRouter()

Expand Down Expand Up @@ -53,6 +58,17 @@ def get_user(db: Session, username: str):
"""Get a user from the database."""
return db.query(UserModel).filter(UserModel.username == username).first()

async def get_user_by_id(db: database_dependency, id: int) -> dict | None:
"""Get a user from the database by id.

Args:
db (database_dependency): The database session.
id (int): User id.

Returns:
dict | None: Data of user with given id, or None if the user id does not exist.
"""
return await db.first(select(UserModel).filter_by(id=id))

def authenticate_user(db: Session, username: str, password: str):
"""Check if a user exists and the password is correct."""
Expand Down Expand Up @@ -185,3 +201,28 @@ async def login(

return await generate_tokens({"id": user.id, "sub": user.username})


@router.post("/login-with-invite")
async def loginWithInvite(
data: dict,
db: Annotated[database_dependency, Depends()]
) -> dict:
"""Login with invitation code.

Args:
data (dict): Data to authenticate the user.
db (Annotated[database_dependency, Depends()]): The database session.

Returns:
Token: The authentication token.
"""

try:
invite = await db.first(select(Invite).filter_by(code=data['code']))
user = await get_user_by_id(db, invite.invitee)
username: str = user.username
tokens = dict(await generate_tokens({"id": user.id, "sub": user.username}))
tokens.update({"username": username, "problem_id": invite.problem_id})
except JWTError:
raise credentials_exception from JWTError
return tokens
37 changes: 37 additions & 0 deletions desdeo/api/security/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing_extensions import Annotated, Doc
from fastapi.param_functions import Form

class InviteForm:
"""
This is a dependency class to collect the `user_id` and `problem_id` as form data
for an invitation.
"""

def __init__(
self,
*,
invitee: Annotated[
int,
Form(),
Doc(
"""
`invitee` int. The InviteForm requires the exact field name
`invitee`.
"""
),
],
problem_id: Annotated[
int,
Form(),
Doc(
"""
`problem_id` int. The InviteForm spec requires the exact field name
`problem_id`.
"""
),
],
):
self.invitee = invitee
self.problem_id = problem_id

8 changes: 8 additions & 0 deletions docs/explanation/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,12 @@ hide:

[:octicons-arrow-right-24: Test problems](./test_problems.md)

- :octicons-info-24:{ .lg .middle } __Log in with invite code__

---

Learn about the invite code for easy log-in

[:octicons-arrow-right-24: Invite code](./invite_code.md)

</div>
46 changes: 46 additions & 0 deletions docs/explanation/invite_code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Invite code

## Requirements

- `invitee`: id of the user to be invited
- `problem_id`: id of the problem

## How to create code?

Location: <api_url>/docs

API: [POST] /invite

Firstly, it requires authorization on `docs`to be able to execute the API. After authorization, fill in the requirements, then execute the API.
The API will return the code in the response.

```json
{
"code": "b85b0b0a7218a7b81725432803.580441"
}
```

## Where is the code stored?

The code is stored in `Invite` table. Each row in `Invite` table includes:
- `inviter`: the user that creates the code
- `invitee`: the user that is invited
- `problem_id`: the problem id associated with the code
- `code`: the invite code
- `date_created`: the date and time that the code is created

## How to login with the code?

The user can log in with the code at: `<webui_url>/$code`, in which `$code` is the invite code.

## What is really happening on the UI?

UI calls API `/login-with-invite`, which fetches the code's info from the db. With the invitee's id, the API responds
to the UI with access and refresh tokens, and invitee's username, which are all needed to authenticate a user on UI.


## Further development

- Do we need `problem_id` value? For now, the `problem_id` value is not being used. It should be removed if it's not used at all.