Further cleanup

This commit is contained in:
Mawoka
2025-11-01 15:57:10 +01:00
parent abd2155469
commit 02c917a28f
16 changed files with 215 additions and 336 deletions
+43 -57
View File
@@ -6,14 +6,13 @@
import asyncio import asyncio
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional, BinaryIO from typing import BinaryIO, Any
from fastapi import HTTPException from fastapi import HTTPException
from openpyxl import load_workbook from openpyxl import load_workbook
import ormar.exceptions
from classquiz.db.models import Quiz, User, InstanceData, QuizQuestion, ABCDQuizAnswer from classquiz.db.models import Quiz, User, QuizQuestion, ABCDQuizAnswer
import xlsxwriter import xlsxwriter
from aiohttp import ClientSession from aiohttp import ClientSession
from io import BytesIO from io import BytesIO
@@ -24,7 +23,7 @@ from classquiz.helpers.hashcash import check as hc_check
settings = settings() settings = settings()
async def get_meili_data(quiz: Quiz) -> dict: async def get_meili_data(quiz: Quiz) -> dict[str, Any]:
return { return {
"id": str(quiz.id), "id": str(quiz.id),
"title": quiz.title, "title": quiz.title,
@@ -35,14 +34,19 @@ async def get_meili_data(quiz: Quiz) -> dict:
} }
async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: dict, player_scores: dict) -> BytesIO: async def generate_spreadsheet(
quiz_results: dict[str, Any],
quiz: Quiz,
player_fields: dict[str, Any],
player_scores: dict[str, Any],
) -> BytesIO:
storage = BytesIO() storage = BytesIO()
workbook = xlsxwriter.Workbook(storage, {"in_memory": True}) workbook = xlsxwriter.Workbook(storage, {"in_memory": True})
player_worksheet = workbook.add_worksheet() player_worksheet = workbook.add_worksheet()
player_worksheet.name = "Players" player_worksheet.name = "Players"
player_worksheet.write(0, 0, "Username") _ = player_worksheet.write(0, 0, "Username")
player_worksheet.write(0, 1, "Score") _ = player_worksheet.write(0, 1, "Score")
player_worksheet.write(0, 2, "Custom-Field") _ = player_worksheet.write(0, 2, "Custom-Field")
for i, player in enumerate(player_scores.keys()): for i, player in enumerate(player_scores.keys()):
player_worksheet.write(i + 1, 0, player) player_worksheet.write(i + 1, 0, player)
player_worksheet.write(i + 1, 1, player_scores[player]) player_worksheet.write(i + 1, 1, player_scores[player])
@@ -53,33 +57,33 @@ async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: di
worksheet = workbook.add_worksheet() worksheet = workbook.add_worksheet()
worksheet.name = "Questions" worksheet.name = "Questions"
worksheet.write(0, 0, "Question") _ = worksheet.write(0, 0, "Question")
worksheet.write(0, 1, "Time") _ = worksheet.write(0, 1, "Time")
worksheet.write(0, 2, "Image") _ = worksheet.write(0, 2, "Image")
worksheet.write(0, 3, "Correct answers") _ = worksheet.write(0, 3, "Correct answers")
worksheet.write(0, 4, "Correct answers") _ = worksheet.write(0, 4, "Correct answers")
worksheet.write(0, 5, "Wrong answers") _ = worksheet.write(0, 5, "Wrong answers")
for i, _ in enumerate(quiz_results): for i, _ in enumerate(quiz_results):
question = quiz.questions[i] question = quiz.questions[i]
# print(quiz_results)
try: try:
answer_data = quiz_results[str(i)] answer_data = quiz_results[str(i)]
except KeyError: except KeyError:
continue continue
worksheet.write(i + 1, 0, question["question"]) _ = worksheet.write(i + 1, 0, question["question"])
worksheet.write(i + 1, 1, question["time"]) _ = worksheet.write(i + 1, 1, question["time"])
try: try:
async with ( async with (
ClientSession() as session, ClientSession() as session,
session.get(f"{settings.root_address}/api/v1/storage/download/{question['image']}") as response, session.get(f"{settings.root_address}/api/v1/storage/download/{question['image']}") as response,
): ):
if "image" in response.headers.get("Content-Type"): content_type = response.headers.get("Content-Type")
if content_type is not None and "image" in content_type:
img_data = BytesIO(await response.read()) img_data = BytesIO(await response.read())
worksheet.insert_image(i + 1, 2, question["image"], {"image_data": img_data}) _ = worksheet.insert_image(i + 1, 2, question["image"], {"image_data": img_data})
image = Image.open(img_data) image = Image.open(img_data)
worksheet.set_row(i + 1, image.height) _ = worksheet.set_row(i + 1, image.height)
worksheet.set_column(2, 2, image.width) _ = worksheet.set_column(2, 2, image.width)
except TypeError: except TypeError:
pass pass
answer_amount = len(answer_data) answer_amount = len(answer_data)
@@ -91,24 +95,24 @@ async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: di
correct_answers += 1 correct_answers += 1
else: else:
wrong_answers += 1 wrong_answers += 1
worksheet.write(i + 1, 3, f"{round(correct_answers / answer_amount * 100)}%") _ = worksheet.write(i + 1, 3, f"{round(correct_answers / answer_amount * 100)}%")
worksheet.write(i + 1, 4, correct_answers) _ = worksheet.write(i + 1, 4, correct_answers)
worksheet.write(i + 1, 5, wrong_answers) _ = worksheet.write(i + 1, 5, wrong_answers)
ws = workbook.add_worksheet(f"{i + 1}. Question") ws = workbook.add_worksheet(f"{i + 1}. Question")
ws.write(0, 0, "Answer") _ = ws.write(0, 0, "Answer")
ws.write(0, 1, "Correct") _ = ws.write(0, 1, "Correct")
ws.write(0, 2, "Username") _ = ws.write(0, 2, "Username")
for j, _ in enumerate(answer_data): for j, _ in enumerate(answer_data):
ws.write(j + 1, 0, answer_data[j]["answer"]) _ = ws.write(j + 1, 0, answer_data[j]["answer"])
if answer_data[j]["right"]: if answer_data[j]["right"]:
ws.write(j + 1, 1, "True") _ = ws.write(j + 1, 1, "True")
else: else:
ws.write(j + 1, 1, "False") _ = ws.write(j + 1, 1, "False")
ws.write(j + 1, 2, answer_data[j]["username"]) _ = ws.write(j + 1, 2, answer_data[j]["username"])
workbook.close() workbook.close()
storage.seek(0) _ = storage.seek(0)
return storage return storage
@@ -118,9 +122,11 @@ async def handle_import_from_excel(data: BinaryIO, user: User) -> Quiz:
except KeyError: except KeyError:
raise HTTPException(status_code=400, detail="File not in excel format") raise HTTPException(status_code=400, detail="File not in excel format")
ws = wb.active ws = wb.active
if ws is None:
raise Exception("Workbook is None")
questions: list[dict] = [] questions: list[dict] = []
title = ws["C5"].value title: str | None = ws["C5"].value
description = ws["C6"].value description: str | None = ws["C6"].value
if title is None: if title is None:
raise HTTPException(status_code=400, detail="Title missing") raise HTTPException(status_code=400, detail="Title missing")
if description is None: if description is None:
@@ -182,12 +188,12 @@ async def handle_import_from_excel(data: BinaryIO, user: User) -> Quiz:
questions=questions, questions=questions,
imported_from_kahoot=False, imported_from_kahoot=False,
) )
await quiz.save() _ = await quiz.save()
else: else:
existing_quiz.questions = [*existing_quiz.questions, *questions] existing_quiz.questions = [*existing_quiz.questions, *questions]
existing_quiz.updated_at = datetime.now() existing_quiz.updated_at = datetime.now()
existing_quiz.mod_rating = None existing_quiz.mod_rating = None
await existing_quiz.update() _ = await existing_quiz.update()
quiz = existing_quiz quiz = existing_quiz
return quiz return quiz
@@ -227,27 +233,7 @@ async def meilisearch_init():
LOGGER.info("Finished MeiliSearch synchronisation") LOGGER.info("Finished MeiliSearch synchronisation")
async def telemetry_ping(): def check_hashcash(data: str, input_data: str, claim_in: str | None = "19") -> bool:
try:
instance_data = await InstanceData.objects.first()
except ormar.exceptions.NoMatch:
instance_data = InstanceData()
await instance_data.save()
async with (
ClientSession() as session,
session.post(
f"https://cit.mawoka.eu.org/public/{instance_data.instance_id}",
json={
"public_quizzes": await Quiz.objects.filter(public=True).count(),
"private_quizzes": await Quiz.objects.filter(public=False).count(),
"users": await User.objects.count(),
},
),
):
return
def check_hashcash(data: str, input_data: str, claim_in: Optional[str] = "19") -> bool:
""" """
It checks that the hashcash is valid, and if it is, it returns True It checks that the hashcash is valid, and if it is, it returns True
+3 -3
View File
@@ -15,8 +15,8 @@ This library is also tested.
```python ```python
from classquiz.kahoot_importer.get import get, _Response from classquiz.kahoot_importer.get import get, _Response
from asyncio import run from asyncio import run
# _Response ia a pydantic-object, so you have access to # _Response is a pydantic-object, so you have access to
# .dict() or .json(exclude={"kahoot"}) # .dict() or .model_dump_json(exclude={"kahoot"})
async def main(): async def main():
kahoot_quiz: _Response = await get("GAME_ID") kahoot_quiz: _Response = await get("GAME_ID")
@@ -31,7 +31,7 @@ run(main())
from classquiz.kahoot_importer.search import search, _Response from classquiz.kahoot_importer.search import search, _Response
from asyncio import run from asyncio import run
# _Response ia a pydantic-object, so you have access to # _Response ia a pydantic-object, so you have access to
# .dict() or .json(exclude={"kahoot"}) # .dict() or .model_dump_json(exclude={"kahoot"})
async def main(): async def main():
kahoot_quizzes: _Response = await search("QUERY") kahoot_quizzes: _Response = await search("QUERY")
+22 -23
View File
@@ -3,7 +3,7 @@
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
from typing import List, Any, Optional from typing import Any
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
@@ -33,10 +33,10 @@ class _LastEdit(BaseModel):
class _ImageMetadata(BaseModel): class _ImageMetadata(BaseModel):
id: UUID | None = None id: UUID | None = None
content_type: Optional[str] = None content_type: str | None = None
width: Optional[int] = None width: int | None = None
height: Optional[int] = None height: int | None = None
resources: Optional[str] = None resources: str | None = None
class _SampleQuestion(BaseModel): class _SampleQuestion(BaseModel):
@@ -48,11 +48,11 @@ class _SampleQuestion(BaseModel):
class _Access(BaseModel): class _Access(BaseModel):
groupRead: List[Any | None] groupRead: list[Any | None]
folderGroupIds: List[Any | None] folderGroupIds: list[Any | None]
class _Card(BaseModel): class Card(BaseModel):
type: str type: str
title: str title: str
description: str description: str
@@ -60,12 +60,12 @@ class _Card(BaseModel):
cover: str | None = None cover: str | None = None
coverMetadata: _CoverMetadata | dict[None, None] | None = None coverMetadata: _CoverMetadata | dict[None, None] | None = None
draftExists: bool draftExists: bool
inventoryItemIds: List[Any] = None inventoryItemIds: list[Any] | None = None
number_of_questions: int number_of_questions: int
creator: UUID creator: UUID
creator_username: str creator_username: str
creator_avatar: _CreatorAvatar | dict[None, None] | None = None creator_avatar: _CreatorAvatar | dict[None, None] | None = None
badges: List[str] badges: list[str]
visibility: int visibility: int
locked: bool locked: bool
writeProtection: bool writeProtection: bool
@@ -76,11 +76,11 @@ class _Card(BaseModel):
draft: bool draft: bool
combined: bool combined: bool
compatibility_level: int compatibility_level: int
sample_questions: List[_SampleQuestion] sample_questions: list[_SampleQuestion]
number_of_plays: int number_of_plays: int
number_of_players: int number_of_players: int
total_favourites: int total_favourites: int
question_types: List[str] question_types: list[str]
created: int created: int
modified: int modified: int
access: _Access access: _Access
@@ -89,7 +89,7 @@ class _Card(BaseModel):
class _Entity(BaseModel): class _Entity(BaseModel):
card: _Card card: Card
class _Origin(BaseModel): class _Origin(BaseModel):
@@ -130,8 +130,8 @@ class _Video(BaseModel):
startTime: float startTime: float
endTime: float endTime: float
service: str service: str
full_url: Optional[str] = None full_url: str | None = None
id: Optional[str] = None id: str | None = None
class _Question(BaseModel): class _Question(BaseModel):
@@ -140,17 +140,17 @@ class _Question(BaseModel):
time: int time: int
points: bool points: bool
pointsMultiplier: int pointsMultiplier: int
choices: List[_Choice] choices: list[_Choice]
image: str | None = None image: str | None = None
imageMetadata: _ImageMetadata | None = None imageMetadata: _ImageMetadata | None = None
resources: Optional[str] = None resources: str | None = None
video: _Video video: _Video
questionFormat: int questionFormat: int
languageInfo: _LanguageInfo | None = None languageInfo: _LanguageInfo | None = None
media: List[Any] media: list[Any]
class _Kahoot(BaseModel): class Kahoot(BaseModel):
uuid: UUID uuid: UUID
language: str language: str
creator: UUID creator: UUID
@@ -161,20 +161,19 @@ class _Kahoot(BaseModel):
visibility: int visibility: int
difficulty: int | None = None difficulty: int | None = None
audience: str audience: str
audience: str
title: str title: str
description: str description: str
quizType: str quizType: str
tags: str | None | List[str] = None tags: str | None | list[str] = None
cover: str | None = None cover: str | None = None
coverMetadata: _CoverMetadata | dict[None, None] | None = None coverMetadata: _CoverMetadata | dict[None, None] | None = None
questions: List[_Question] questions: list[_Question]
metadata: _Metadata metadata: _Metadata
parent: _Parent | None = None parent: _Parent | None = None
resources: str | None = None resources: str | None = None
slug: str slug: str
languageInfo: _LanguageInfo | None = None languageInfo: _LanguageInfo | None = None
inventoryItemIds: List[Any] inventoryItemIds: list[Any]
type: str type: str
created: int created: int
modified: int modified: int
+3 -3
View File
@@ -6,12 +6,12 @@
from aiohttp import ClientSession from aiohttp import ClientSession
from pydantic import BaseModel from pydantic import BaseModel
from classquiz.kahoot_importer import _Card, _Kahoot from classquiz.kahoot_importer import Card, Kahoot
class _Response(BaseModel): class _Response(BaseModel):
card: _Card card: Card
kahoot: _Kahoot kahoot: Kahoot
async def get(game_id: str) -> _Response | int: async def get(game_id: str) -> _Response | int:
+2 -2
View File
@@ -58,7 +58,7 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | int:
if type(quiz) is int: if type(quiz) is int:
return quiz return quiz
quiz_questions: list[dict] = [] quiz_questions: list[dict] = []
quiz_id = uuid.uuid4() new_quiz_id = uuid.uuid4()
meilisearch.delete_index(settings.meilisearch_index) meilisearch.delete_index(settings.meilisearch_index)
meilisearch.create_index(settings.meilisearch_index) meilisearch.create_index(settings.meilisearch_index)
uploaded_images: list[StorageItem] = [] uploaded_images: list[StorageItem] = []
@@ -97,7 +97,7 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | int:
if quiz.kahoot.description is None or quiz.kahoot.description == "": if quiz.kahoot.description is None or quiz.kahoot.description == "":
quiz.kahoot.description = "Description Missing!" quiz.kahoot.description = "Description Missing!"
quiz_data = Quiz( quiz_data = Quiz(
id=quiz_id, id=new_quiz_id,
public=False, public=False,
title=bleach.clean(quiz.kahoot.title, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True), title=bleach.clean(quiz.kahoot.title, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True),
description=bleach.clean(quiz.kahoot.description, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True), description=bleach.clean(quiz.kahoot.description, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True),
+2 -4
View File
@@ -3,8 +3,6 @@
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
from typing import List
from aiohttp import ClientSession from aiohttp import ClientSession
from pydantic import BaseModel from pydantic import BaseModel
@@ -13,7 +11,7 @@ from classquiz.kahoot_importer import _Entity
# noqa : E501 # noqa : E501
class _Response(BaseModel): class _Response(BaseModel):
entities: List[_Entity] entities: list[_Entity]
totalHits: int totalHits: int
cursor: int | None = None cursor: int | None = None
pageTimestamp: int pageTimestamp: int
@@ -28,7 +26,7 @@ async def search(
) -> _Response: ) -> _Response:
""" """
:param inventory_item_id: I dkon't know :param inventory_item_id: I don't know
:param search_cluster: Doesn't seeem to matter :param search_cluster: Doesn't seeem to matter
:param cursor: The position in the result-list (page) :param cursor: The position in the result-list (page)
:param query: The search query :param query: The search query
+3 -3
View File
@@ -22,7 +22,7 @@ router.include_router(github.router, prefix="/github")
router.include_router(custom.router, prefix="/custom") router.include_router(custom.router, prefix="/custom")
async def rememberme_middleware(request: Request, call_next): async def rememberme_middleware(request: Request, call_next) -> Response:
rememberme_cookie = request.cookies.get("rememberme_token") rememberme_cookie = request.cookies.get("rememberme_token")
bearer_token = request.cookies.get("access_token") bearer_token = request.cookies.get("access_token")
conditions_to_handle_met = True conditions_to_handle_met = True
@@ -41,13 +41,13 @@ async def rememberme_middleware(request: Request, call_next):
# Verifying the bearer # Verifying the bearer
try: try:
jwt.decode( _ = jwt.decode(
param, settings.secret_key, algorithms=["HS256"] param, settings.secret_key, algorithms=["HS256"]
) # checking if the token is valid, throws error if not ) # checking if the token is valid, throws error if not
conditions_to_handle_met = False conditions_to_handle_met = False
except JWTError: except JWTError:
try: try:
jws.verify( _ = jws.verify(
param, settings.secret_key, algorithms=["HS256"] param, settings.secret_key, algorithms=["HS256"]
) # Verifying only the signature of the jwt, throws error if signature is invalid ) # Verifying only the signature of the jwt, throws error if signature is invalid
except JWSError: except JWSError:
+11 -6
View File
@@ -14,18 +14,19 @@ from classquiz.auth import create_access_token
settings = settings() settings = settings()
async def log_user_in(user: User, request: Request, response: Response): async def log_user_in(user: User | None, request: Request, response: Response):
if user is None: if user is None:
raise HTTPException(status_code=401, detail="User not matched!") raise HTTPException(status_code=401, detail="User not matched!")
remote_ip = None remote_ip = None
if request.headers.get("X-Forwarded-For") is None: forwarded_for_header = request.headers.get("X-Forwarded-For")
if forwarded_for_header is None:
remote_ip = request.client.host remote_ip = request.client.host
else: else:
if "," in request.headers.get("X-Forwarded-For"): if "," in forwarded_for_header:
remote_ip = request.headers.get("X-Forwarded-For").split(", ")[0] remote_ip = forwarded_for_header.split(", ")[0]
else: else:
remote_ip = request.headers.get("X-Forwarded-For") remote_ip = forwarded_for_header
session_key = os.urandom(32).hex() session_key = os.urandom(32).hex()
user_session = UserSession( user_session = UserSession(
user=user, user=user,
@@ -47,7 +48,11 @@ async def log_user_in(user: User, request: Request, response: Response):
max_age=60 * 60 * 24 * 365, max_age=60 * 60 * 24 * 365,
) )
response.set_cookie( response.set_cookie(
key="rememberme_token", value=session_key, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 365 key="rememberme_token",
value=session_key,
httponly=True,
samesite="lax",
max_age=60 * 60 * 24 * 365,
) )
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
+7 -23
View File
@@ -77,9 +77,6 @@ async def auth(request: Request, response: Response):
except (TypeError, ValidationError): except (TypeError, ValidationError):
raise HTTPException(status_code=401, detail="Something went wrong.") raise HTTPException(status_code=401, detail="Something went wrong.")
user_in_db = await User.objects.get_or_none(email=user_data.email)
if user_in_db is None:
# REGISTER USER
try: try:
await User.objects.create( await User.objects.create(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -91,29 +88,16 @@ async def auth(request: Request, response: Response):
avatar=gzipped_user_avatar(), avatar=gzipped_user_avatar(),
) )
# skipcq: PYL-W0703 # skipcq: PYL-W0703
except Exception as e:
if type(e) is asyncpg.exceptions.UniqueViolationError:
error = True
counter = 1
while error:
try:
await User.objects.create(
id=uuid.uuid4(),
email=user_data.email,
username=f"{user_data.preferred_username}{counter}",
verified=user_data.email_verified,
auth_type=UserAuthTypes.CUSTOM,
google_uid=user_data.sub.hex,
avatar=gzipped_user_avatar(),
)
error = False
except asyncpg.exceptions.UniqueViolationError: except asyncpg.exceptions.UniqueViolationError:
counter += 1 # Most likely a duplicate email/username, not UUID.
error = True raise HTTPException(status_code=400, detail="User already exists.")
else: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
user = await User.objects.get_or_none( user = await User.objects.get_or_none(
email=user_data.email, google_uid=user_data.sub.hex, auth_type=UserAuthTypes.CUSTOM, verified=True email=user_data.email,
google_uid=user_data.sub.hex,
auth_type=UserAuthTypes.CUSTOM,
verified=True,
) )
print(user_data) print(user_data)
+37 -57
View File
@@ -4,7 +4,6 @@
import uuid import uuid
from typing import Optional
import asyncpg import asyncpg
import authlib.integrations.base_client import authlib.integrations.base_client
@@ -35,43 +34,43 @@ class Plan(BaseModel):
class GitHubOauthResponse(BaseModel): class GitHubOauthResponse(BaseModel):
login: str login: str
id: int id: int
email: Optional[str] = None email: str | None = None
node_id: Optional[str] = None node_id: str | None = None
avatar_url: Optional[str] = None avatar_url: str | None = None
gravatar_id: Optional[str] = None gravatar_id: str | None = None
url: Optional[str] = None url: str | None = None
html_url: Optional[str] = None html_url: str | None = None
followers_url: Optional[str] = None followers_url: str | None = None
following_url: Optional[str] = None following_url: str | None = None
gists_url: Optional[str] = None gists_url: str | None = None
starred_url: Optional[str] = None starred_url: str | None = None
subscriptions_url: Optional[str] = None subscriptions_url: str | None = None
organizations_url: Optional[str] = None organizations_url: str | None = None
repos_url: Optional[str] = None repos_url: str | None = None
events_url: Optional[str] = None events_url: str | None = None
received_events_url: Optional[str] = None received_events_url: str | None = None
type: Optional[str] = None type: str | None = None
site_admin: Optional[bool] = None site_admin: bool | None = None
name: Optional[str] = None name: str | None = None
company: Optional[str] = None company: str | None = None
blog: Optional[str] = None blog: str | None = None
location: Optional[str] = None location: str | None = None
hireable: Optional[bool] = None hireable: bool | None = None
bio: Optional[str] = None bio: str | None = None
twitter_username: Optional[str] = None twitter_username: str | None = None
public_repos: Optional[int] = None public_repos: int | None = None
public_gists: Optional[int] = None public_gists: int | None = None
followers: Optional[int] = None followers: int | None = None
following: Optional[int] = None following: int | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
private_gists: Optional[int] = None private_gists: int | None = None
total_private_repos: Optional[int] = None total_private_repos: int | None = None
owned_private_repos: Optional[int] = None owned_private_repos: int | None = None
disk_usage: Optional[int] = None disk_usage: int | None = None
collaborators: Optional[int] = None collaborators: int | None = None
two_factor_authentication: Optional[bool] = None two_factor_authentication: bool | None = None
plan: Optional[Plan] = None plan: Plan | None = None
@router.get("/login") @router.get("/login")
@@ -107,8 +106,6 @@ async def auth(request: Request, response: Response):
user_data = GitHubOauthResponse(**data) user_data = GitHubOauthResponse(**data)
if user_data.email is None: if user_data.email is None:
return RedirectResponse("/account/oauth-error?error=email") return RedirectResponse("/account/oauth-error?error=email")
user_in_db = await User.objects.get_or_none(email=user_data.email)
if user_in_db is None:
# REGISTER USER # REGISTER USER
try: try:
await User.objects.create( await User.objects.create(
@@ -119,26 +116,9 @@ async def auth(request: Request, response: Response):
auth_type=UserAuthTypes.GITHUB, auth_type=UserAuthTypes.GITHUB,
avatar=gzipped_user_avatar(), avatar=gzipped_user_avatar(),
) )
# skipcq: PYL-W0703
except Exception as e:
if type(e) is asyncpg.exceptions.UniqueViolationError:
error = True
counter = 1
while error:
try:
await User.objects.create(
id=uuid.uuid4(),
email=user_data.email,
username=user_data.login,
verified=True,
auth_type=UserAuthTypes.GITHUB,
avatar=gzipped_user_avatar(),
)
error = False
except asyncpg.exceptions.UniqueViolationError: except asyncpg.exceptions.UniqueViolationError:
counter += 1 raise HTTPException(status_code=400, detail="User already exists.")
error = True except Exception as e:
else:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
user = await User.objects.get_or_none( user = await User.objects.get_or_none(
email=user_data.email, email=user_data.email,
+6 -23
View File
@@ -79,8 +79,6 @@ async def auth(request: Request, response: Response):
user_data = OauthGoogleResponse(**user_data).userinfo user_data = OauthGoogleResponse(**user_data).userinfo
except (TypeError, ValidationError): except (TypeError, ValidationError):
raise HTTPException(status_code=401, detail="Something went wrong.") raise HTTPException(status_code=401, detail="Something went wrong.")
user_in_db = await User.objects.get_or_none(email=user_data.email)
if user_in_db is None:
# REGISTER USER # REGISTER USER
try: try:
await User.objects.create( await User.objects.create(
@@ -92,30 +90,15 @@ async def auth(request: Request, response: Response):
google_uid=user_data.sub, google_uid=user_data.sub,
avatar=gzipped_user_avatar(), avatar=gzipped_user_avatar(),
) )
# skipcq: PYL-W0703
except Exception as e:
if type(e) is asyncpg.exceptions.UniqueViolationError:
error = True
counter = 1
while error:
try:
await User.objects.create(
id=uuid.uuid4(),
email=user_data.email,
username=f"{user_data.name}{counter}",
verified=user_data.email_verified,
auth_type=UserAuthTypes.GOOGLE,
google_uid=user_data.sub,
avatar=gzipped_user_avatar(),
)
error = False
except asyncpg.exceptions.UniqueViolationError: except asyncpg.exceptions.UniqueViolationError:
counter += 1 raise HTTPException(status_code=400, detail="User already exists.")
error = True except Exception as e:
else:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
user = await User.objects.get_or_none( user = await User.objects.get_or_none(
email=user_data.email, google_uid=user_data.sub, auth_type=UserAuthTypes.GOOGLE, verified=True email=user_data.email,
google_uid=user_data.sub,
auth_type=UserAuthTypes.GOOGLE,
verified=True,
) )
await log_user_in(user=user, request=request, response=response) await log_user_in(user=user, request=request, response=response)
+3 -3
View File
@@ -14,15 +14,15 @@ router = APIRouter()
@router.delete("/user/id") @router.delete("/user/id")
async def delete_user_by_id(user_id: UUID, user: User = Depends(get_admin_user)): async def delete_user_by_id(user_id: UUID, _: User = Depends(get_admin_user)):
return {"deleted": await User.objects.delete(id=user_id)} return {"deleted": await User.objects.delete(id=user_id)}
@router.delete("/user/username") @router.delete("/user/username")
async def delete_user_by_username(username: str, user: User = Depends(get_admin_user)): async def delete_user_by_username(username: str, _: User = Depends(get_admin_user)):
return {"deleted": await User.objects.delete(username=username)} return {"deleted": await User.objects.delete(username=username)}
@router.delete("/user/email") @router.delete("/user/email")
async def delete_user_by_email(email: str, user: User = Depends(get_admin_user)): async def delete_user_by_email(email: str, _: User = Depends(get_admin_user)):
return {"deleted": await User.objects.delete(email=email)} return {"deleted": await User.objects.delete(email=email)}
-13
View File
@@ -83,19 +83,6 @@ async def get_customized_avatar(
clothe_graphic_type=clothe_graphic_type, clothe_graphic_type=clothe_graphic_type,
).render_svg() ).render_svg()
# skipcq: PY-W0069 # skipcq: PY-W0069
# print(f"skin_color: {len(AvatarItemsAsList.skin_color)},")
# print(f"hair_color: {len(AvatarItemsAsList.hair_color)},")
# print(f"facial_hair_type: {len(AvatarItemsAsList.facial_hair_type)},")
# print(f"facial_hair_color: {len(AvatarItemsAsList.facial_hair_color)},")
# print(f"top_type: {len(AvatarItemsAsList.top_type)},")
# print(f"hat_color: {len(AvatarItemsAsList.hat_color)},")
# print(f"mouth_type: {len(AvatarItemsAsList.mouth_type)},")
# print(f"eyebrow_type: {len(AvatarItemsAsList.eyebrow_type)},")
# print(f"nose_type: {len(AvatarItemsAsList.nose_type)},")
# print(f"accessories_type: {len(AvatarItemsAsList.accessories_type)},")
# print(f"clothe_type: {len(AvatarItemsAsList.clothe_type)},")
# print(f"clothe_color: {len(AvatarItemsAsList.clothe_color)},")
# print(f"clothe_graphic_type: {len(AvatarItemsAsList.clothe_graphic_type)},")
return avatar return avatar
+17 -5
View File
@@ -17,18 +17,24 @@ router = APIRouter()
# #
@router.get("/user/{user_id}", response_model_include={"username", "created_at", "id"}, response_model=User) @router.get(
"/user/{user_id}",
response_model_include={"username", "created_at", "id"},
response_model=User,
)
async def get_user_by_user_id(user_id: UUID): async def get_user_by_user_id(user_id: UUID):
user = await User.objects.get_or_none(id=user_id) user = await User.objects.get_or_none(id=user_id)
# .select_related("quizs")
# print(user)
if user is None: if user is None:
raise HTTPException(status_code=404, detail="user not found") raise HTTPException(status_code=404, detail="user not found")
else: else:
return user return user
@router.get("/quizzes/{user_id}", response_model_exclude={"questions", "user_id"}, response_model=list[Quiz]) @router.get(
"/quizzes/{user_id}",
response_model_exclude={"questions", "user_id"},
response_model=list[Quiz],
)
async def get_quizzes_from_user(user_id: UUID, imported: bool | None = None): async def get_quizzes_from_user(user_id: UUID, imported: bool | None = None):
if imported is None: if imported is None:
quizzes = await Quiz.objects.all(user_id=user_id, public=True) quizzes = await Quiz.objects.all(user_id=user_id, public=True)
@@ -68,7 +74,13 @@ async def rate_quiz(data: RateQuizInput, quiz_id: uuid.UUID, user: User = Depend
quiz.likes -= 1 quiz.likes -= 1
else: else:
quiz.dislikes -= 1 quiz.dislikes -= 1
rating = Rating(id=uuid.uuid4(), user=user, positive=positive, quiz=quiz, created_at=datetime.now()) rating = Rating(
id=uuid.uuid4(),
user=user,
positive=positive,
quiz=quiz,
created_at=datetime.now(),
)
await rating.save() await rating.save()
if positive: if positive:
quiz.likes += 1 quiz.likes += 1
+2 -2
View File
@@ -37,7 +37,7 @@ class UUIDEncoder(json.JSONEncoder):
@router.get("/{quiz_id}") @router.get("/{quiz_id}")
async def export_quiz(quiz_id: uuid.UUID, user: User = Depends(get_current_user)): async def export_quiz(quiz_id: uuid.UUID, _: User = Depends(get_current_user)):
try: try:
quiz: Quiz = await Quiz.objects.filter(Quiz.id == quiz_id).first() quiz: Quiz = await Quiz.objects.filter(Quiz.id == quiz_id).first()
except ormar.exceptions.NoMatch: except ormar.exceptions.NoMatch:
@@ -138,7 +138,7 @@ async def import_quiz(file: UploadFile = File(), user: User = Depends(get_curren
@router.get("/excel/{quiz_id}") @router.get("/excel/{quiz_id}")
async def export_quiz_as_excel(quiz_id: uuid.UUID, user: User = Depends(get_current_user)): async def export_quiz_as_excel(quiz_id: uuid.UUID, _: User = Depends(get_current_user)):
try: try:
quiz: Quiz = await Quiz.objects.filter(Quiz.id == quiz_id).first() quiz: Quiz = await Quiz.objects.filter(Quiz.id == quiz_id).first()
except ormar.exceptions.NoMatch: except ormar.exceptions.NoMatch:
+18 -73
View File
@@ -10,22 +10,18 @@ from datetime import datetime
import ormar import ormar
import pydantic import pydantic
from email_validator import validate_email, EmailNotValidError from email_validator import validate_email, EmailNotValidError
from fastapi import APIRouter, Response, HTTPException, Request, Depends, status from fastapi import APIRouter, Response, HTTPException, Request, Depends
from fastapi.background import BackgroundTasks from fastapi.background import BackgroundTasks
from fastapi.responses import JSONResponse, RedirectResponse, PlainTextResponse from fastapi.responses import JSONResponse, RedirectResponse, PlainTextResponse
from fastapi.security import OAuth2PasswordRequestForm
from jose import jwt, JWTError
from classquiz import oauth from classquiz import oauth
from classquiz.helpers.avatar import gzipped_user_avatar from classquiz.helpers.avatar import gzipped_user_avatar
import base64 import base64
from classquiz.oauth.authenticate_user import rememberme_check, log_user_in
from classquiz.auth import ( from classquiz.auth import (
get_password_hash, get_password_hash,
verify_password, verify_password,
authenticate_user,
get_current_user, get_current_user,
) )
from classquiz.cache import clear_cache_for_account from classquiz.cache import clear_cache_for_account
@@ -33,7 +29,7 @@ from classquiz.config import redis, settings, meilisearch
import uuid import uuid
import bleach import bleach
from pydantic import BaseModel from pydantic import BaseModel
from classquiz.db.models import User, UserSession, UpdatePassword, Token, Quiz, ApiKey from classquiz.db.models import User, UserSession, UpdatePassword, Quiz, ApiKey
from classquiz.emails import send_register_email, send_forgotten_password_email from classquiz.emails import send_register_email, send_forgotten_password_email
from classquiz.routers.users import webauthn, twofa from classquiz.routers.users import webauthn, twofa
@@ -66,7 +62,12 @@ router.include_router(oauth.router, tags=["users", "oauth"], prefix="/oauth")
async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User | JSONResponse: async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User | JSONResponse:
if settings.registration_disabled: if settings.registration_disabled:
raise HTTPException(status_code=423) raise HTTPException(status_code=423)
user = User(**user.model_dump(), id=uuid.uuid4(), avatar=gzipped_user_avatar(), created_at=datetime.now()) user: User = User(
**user.model_dump(),
id=uuid.uuid4(),
avatar=gzipped_user_avatar(),
created_at=datetime.now(),
)
try: try:
validate_email(user.email) validate_email(user.email)
except EmailNotValidError as e: except EmailNotValidError as e:
@@ -91,31 +92,6 @@ async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User
return user return user
@router.post("/token/cookie", response_model=Token, deprecated=True)
async def login_for_cookie_access_token(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
):
user = await authenticate_user(form_data.username, form_data.password)
user = await User.objects.select_related("fidocredentialss").get(id=user.id)
if not user or user.totp_secret is not None or user or len(user.fidocredentialss) != 0:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
return await log_user_in(response=response, request=request, user=user)
@router.get("/token/rememberme")
async def rememberme_token(request: Request, response: Response):
rememberme_token_lol = request.cookies.get("rememberme_token")
if rememberme_token_lol is None:
raise HTTPException(status_code=400, detail="No rememberme cookie")
return await rememberme_check(rememberme_token=rememberme_token_lol, response=response)
@router.get("/logout") @router.get("/logout")
async def logout(request: Request, response: Response): async def logout(request: Request, response: Response):
remember_token = request.cookies.get("rememberme_token") remember_token = request.cookies.get("rememberme_token")
@@ -229,24 +205,26 @@ async def reset_password_with_token(reset_password: ResetPassword, response: Res
return {"message": "Password updated successfully"} return {"message": "Password updated successfully"}
@router.get("/sessions/list", response_model=list[UserSession], response_model_exclude={"user", "session_key", "quizs"}) @router.get(
"/sessions/list",
response_model=list[UserSession],
response_model_exclude={"user", "session_key", "quizs"},
)
async def list_sessions(user: User = Depends(get_current_user)): async def list_sessions(user: User = Depends(get_current_user)):
sessions = await UserSession.objects.filter(user=user).all() sessions = await UserSession.objects.filter(user=user).all()
return [session.model_dump() for session in sessions] return [session.model_dump() for session in sessions]
@router.delete("/sessions/{session_id}") @router.delete("/sessions/{session_id}")
async def delete_session(session_id: str, user: User = Depends(get_current_user)): async def delete_session(session_id: uuid.UUID, user: User = Depends(get_current_user)):
try:
session_id = uuid.UUID(session_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid session id")
await UserSession.objects.filter(user=user, id=session_id).delete() await UserSession.objects.filter(user=user, id=session_id).delete()
return {"message": "Session deleted"} return {"message": "Session deleted"}
@router.get( @router.get(
"/session", response_model=UserSession, response_model_exclude={"user": ..., "session_key": ..., "quizs": ...} "/session",
response_model=UserSession,
response_model_exclude={"user": ..., "session_key": ..., "quizs": ...},
) )
async def get_session(request: Request, user: User = Depends(get_current_user)): async def get_session(request: Request, user: User = Depends(get_current_user)):
try: try:
@@ -300,43 +278,10 @@ class InternalAuthData(BaseModel):
jwt: str | None = None jwt: str | None = None
@router.post("/auth/internal")
async def internal_auth(data: InternalAuthData, resp: Response):
try:
data.jwt = data.jwt.replace("Bearer ", "")
except AttributeError:
pass
if data.jwt is not None:
try:
payload = jwt.decode(data.jwt, settings.secret_key, algorithms=["HS256"])
email: str = payload.get("sub")
if email is None:
resp.status_code = 401
return resp
except JWTError:
resp.status_code = 401
return resp
else:
return await rememberme_check(data.rememberme, resp)
class GetEmailFromJWT(BaseModel): class GetEmailFromJWT(BaseModel):
jwt: str jwt: str
@router.post("/auth/internal/email")
async def get_email_from_jwt(data: GetEmailFromJWT):
try:
data.jwt = data.jwt.replace("Bearer ", "")
except AttributeError:
pass
try:
payload = jwt.decode(data.jwt, settings.secret_key, algorithms=["HS256"])
return payload.get("sub")
except JWTError:
raise HTTPException(status_code=401)
@router.post("/api_keys", response_model=ApiKey, response_model_include={"key"}) @router.post("/api_keys", response_model=ApiKey, response_model_include={"key"})
async def generate_api_key(user: User = Depends(get_current_user)): async def generate_api_key(user: User = Depends(get_current_user)):
key = ApiKey(key=os.urandom(24).hex(), user=user) key = ApiKey(key=os.urandom(24).hex(), user=user)
@@ -351,7 +296,7 @@ async def list_api_keys(user: User = Depends(get_current_user)):
@router.delete("/api_keys") @router.delete("/api_keys")
async def delete_api_key(api_key: str, user: User = Depends(get_current_user)): async def delete_api_key(api_key: str, _: User = Depends(get_current_user)):
key = await ApiKey.objects.get_or_none(key=api_key) key = await ApiKey.objects.get_or_none(key=api_key)
if key is None: if key is None:
raise HTTPException(status_code=404, detail="Key not found") raise HTTPException(status_code=404, detail="Key not found")