Merge pull request #22 from mawoka-myblock/public

This commit is contained in:
Mawoka
2022-04-14 16:11:29 +02:00
committed by GitHub
29 changed files with 697 additions and 116 deletions
+1
View File
@@ -5,6 +5,7 @@
reverse_proxy /openapi.json localhost:8000
reverse_proxy /socket.io* localhost:8000
reverse_proxy /socket.io/* localhost:8000
reverse_proxy /ms http://localhost:7700
}
+4
View File
@@ -22,6 +22,7 @@ jinja2 = "*"
argon2-cffi = "*"
sentry-sdk = "*"
aiofiles = "*"
meilisearch = "*"
[dev-packages]
coverage = "*"
@@ -32,3 +33,6 @@ black = "*"
[requires]
python_version = "3.10"
[scripts]
format = "black ."
Generated
+35 -19
View File
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "075e07880b28878645b61e807c6091ae0ecdef50b3486e047aa853023fc2ae33"
"sha256": "7e2c8532f63f702ec73822fc0a4cab8fdf54de46dcd6be01aee4fbd0e5a8937e"
},
"pipfile-spec": 6,
"requires": {
@@ -299,7 +299,7 @@
"sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
"sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
],
"markers": "python_version >= '3.5'",
"markers": "python_full_version >= '3.5.0'",
"version": "==2.0.12"
},
"click": {
@@ -544,7 +544,7 @@
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
],
"markers": "python_version >= '3.5'",
"markers": "python_full_version >= '3.5.0'",
"version": "==3.3"
},
"jinja2": {
@@ -609,6 +609,14 @@
"markers": "python_version >= '3.7'",
"version": "==2.1.1"
},
"meilisearch": {
"hashes": [
"sha256:437037dec8730d047e790e94d9af43b59b076fa7511593739aae857eafd83a1b",
"sha256:907986a37b8072b3e27b19b4ee2bb7a1a6f4c9fae961f240aaceadec3adbbab2"
],
"index": "pypi",
"version": "==0.18.2"
},
"multidict": {
"hashes": [
"sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60",
@@ -831,11 +839,11 @@
},
"pyparsing": {
"hashes": [
"sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea",
"sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"
"sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954",
"sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"
],
"markers": "python_version >= '3.6'",
"version": "==3.0.7"
"markers": "python_full_version >= '3.6.8'",
"version": "==3.0.8"
},
"python-dotenv": {
"hashes": [
@@ -928,6 +936,14 @@
"index": "pypi",
"version": "==4.2.2"
},
"requests": {
"hashes": [
"sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
"sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==2.27.1"
},
"rsa": {
"hashes": [
"sha256:5c6bd9dc7a543b7fe4304a631f8a8a3b674e2bbfc49c2ae96200cdbe55df6b17",
@@ -938,19 +954,19 @@
},
"sentry-sdk": {
"hashes": [
"sha256:32af1a57954576709242beb8c373b3dbde346ac6bd616921def29d68846fb8c3",
"sha256:38fd16a92b5ef94203db3ece10e03bdaa291481dd7e00e77a148aa0302267d47"
"sha256:02c0f79941964297341c14d53e4df09a81c949707bd91e2d5e66b565aad75c8b",
"sha256:afd9f92711556739172f3bc845a02fc921ca1560bee974ae65072ef6a30e3921"
],
"index": "pypi",
"version": "==1.5.8"
"version": "==1.5.9"
},
"setuptools": {
"hashes": [
"sha256:7999cbd87f1b6e1f33bf47efa368b224bed5e27b5ef2c4d46580186cbcb1a86a",
"sha256:a65e3802053e99fc64c6b3b29c11132943d5b8c8facbcc461157511546510967"
"sha256:26ead7d1f93efc0f8c804d9fafafbe4a44b179580a7105754b245155f9af05a8",
"sha256:47c7b0c0f8fc10eec4cf1e71c6fdadf8decaa74ffa087e68cd1c20db7ad6a592"
],
"markers": "python_version >= '3.7'",
"version": "==62.0.0"
"version": "==62.1.0"
},
"six": {
"hashes": [
@@ -965,7 +981,7 @@
"sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663",
"sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"
],
"markers": "python_version >= '3.5'",
"markers": "python_full_version >= '3.5.0'",
"version": "==1.2.0"
},
"sqlalchemy": {
@@ -1454,11 +1470,11 @@
},
"pyparsing": {
"hashes": [
"sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea",
"sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"
"sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954",
"sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"
],
"markers": "python_version >= '3.6'",
"version": "==3.0.7"
"markers": "python_full_version >= '3.6.8'",
"version": "==3.0.8"
},
"pytest": {
"hashes": [
@@ -1482,7 +1498,7 @@
"sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
"sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
],
"markers": "python_version < '3.11'",
"markers": "python_version >= '3.7'",
"version": "==2.0.1"
}
}
+2 -1
View File
@@ -5,7 +5,7 @@ from socketio import ASGIApp
from classquiz.config import settings
from classquiz.db import database
from classquiz.routers import users, quiz, utils, stats, storage
from classquiz.routers import users, quiz, utils, stats, storage, search
from classquiz.socket_server import sio
settings = settings()
@@ -46,4 +46,5 @@ app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz")
app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils")
app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats")
app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage")
app.include_router(search.router, tags=["search"], prefix="/api/v1/search")
app.mount("/", ASGIApp(sio))
+5
View File
@@ -2,6 +2,7 @@ from functools import lru_cache
import redis.asyncio as redis_lib
from pydantic import BaseSettings, RedisDsn, PostgresDsn
import meilisearch as MeiliSearch
from classquiz.storage import Storage
@@ -25,6 +26,8 @@ class Settings(BaseSettings):
access_token_expire_minutes: int = 30
cache_expiry: int = 86400
sentry_dsn: str | None = "https://4981de1f72f24fd7b5e21b8913b93a02@o661934.ingest.sentry.io/6254641"
meilisearch_url: str
meilisearch_index: str = "classquiz"
# storage_backend
storage_backend: str | None = "deta"
@@ -52,3 +55,5 @@ storage: Storage = Storage(
deta_id=settings().deta_project_id,
storage_path=settings().storage_path,
)
meilisearch = MeiliSearch.Client(settings().meilisearch_url)
+3 -2
View File
@@ -1,5 +1,6 @@
import uuid
from datetime import datetime
from typing import Optional
import ormar
from pydantic import BaseModel, Json
@@ -32,7 +33,7 @@ class UserSession(ormar.Model):
"""
id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4())
user: uuid.UUID = ormar.ForeignKey(User)
user: Optional[User] = ormar.ForeignKey(User)
session_key: str = ormar.String(unique=True, max_length=64)
created_at: datetime = ormar.DateTime(default=datetime.now())
ip_address: str = ormar.String(max_length=100, nullable=True)
@@ -71,7 +72,7 @@ class Quiz(ormar.Model):
description: str = ormar.String(max_length=300, nullable=True)
created_at: datetime = ormar.DateTime(default=datetime.now())
updated_at: datetime = ormar.DateTime(default=datetime.now())
user_id: uuid.UUID = ormar.UUID(foreign_key=User.id)
user_id: uuid.UUID = ormar.ForeignKey(User)
questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False)
class Meta:
+12 -2
View File
@@ -5,7 +5,7 @@ from datetime import datetime
from aiohttp import ClientSession
from classquiz.config import settings, storage
from classquiz.config import settings, storage, meilisearch
from classquiz.db.models import Quiz, QuizAnswer, QuizQuestion, User
from classquiz.kahoot_importer.get import get as get_quiz
@@ -88,7 +88,7 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
)
quiz_data = Quiz(
id=quiz_id,
public=False,
public=True,
title=quiz.kahoot.title,
description=quiz.kahoot.description,
created_at=datetime.now(),
@@ -96,4 +96,14 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
user_id=user.id,
questions=json.dumps(quiz_questions),
)
meilisearch.index(settings.meilisearch_index).add_documents(
[
{
"id": str(quiz_data.id),
"title": quiz_data.title,
"description": quiz_data.description,
"user": (await User.objects.filter(id=quiz_data.user_id).first()).username,
}
]
)
return await quiz_data.save()
+55 -5
View File
@@ -8,14 +8,13 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from classquiz.auth import get_current_user, get_current_user_optional
from classquiz.config import redis, settings, storage
from classquiz.auth import get_current_user
from classquiz.config import redis, settings, storage, meilisearch
from classquiz.db.models import Quiz, QuizInput, User, PlayGame
from classquiz.kahoot_importer.import_quiz import import_quiz
settings = settings()
router = APIRouter()
@@ -31,11 +30,21 @@ async def create_quiz_lol(quiz_input: QuizInput, user: User = Depends(get_curren
raise HTTPException(status_code=400, detail="image url is not valid")
quiz = Quiz(**quiz_input.dict(), user_id=user.id, id=uuid.uuid4())
await redis.delete("global_quiz_count")
meilisearch.index(settings.meilisearch_index).add_documents(
[
{
"id": str(quiz.id),
"title": quiz.title,
"description": quiz.description,
"user": (await User.objects.filter(id=quiz.user_id).first()).username,
}
]
)
return await quiz.save()
@router.get("/get/{quiz_id}")
async def get_quiz_from_id(quiz_id: str, user: User | None = Depends(get_current_user_optional)):
async def get_quiz_from_id(quiz_id: str, user: User | None = Depends(get_current_user)):
try:
quiz_id = uuid.UUID(quiz_id)
except ValueError:
@@ -54,6 +63,19 @@ async def get_quiz_from_id(quiz_id: str, user: User | None = Depends(get_current
return quiz
@router.get("/get/public/{quiz_id}", response_model=Quiz)
async def get_public_quiz(quiz_id: str):
try:
quiz_id = uuid.UUID(quiz_id)
except ValueError:
raise HTTPException(status_code=400, detail="badly formed quiz id")
quiz = await Quiz.objects.get_or_none(id=quiz_id, public=True)
if quiz is None:
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
else:
return quiz
@router.post("/start/{quiz_id}")
async def start_quiz(quiz_id: str, user: User = Depends(get_current_user)):
try:
@@ -62,7 +84,9 @@ async def start_quiz(quiz_id: str, user: User = Depends(get_current_user)):
raise HTTPException(status_code=400, detail="badly formed quiz id")
quiz = await Quiz.objects.get_or_none(id=quiz_id, user_id=user.id)
if quiz is None:
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
quiz = await Quiz.objects.get_or_none(id=quiz_id, public=True)
if quiz is None:
return JSONResponse(status_code=404, content={"detail": "quiz not found"})
else:
game_pin = randint(10000000, 99999999)
game = PlayGame(
@@ -112,11 +136,36 @@ async def update_quiz(quiz_id: str, quiz_input: QuizInput, user: User = Depends(
else:
# print(quiz_input)
# print(quiz)
meilisearch.index(settings.meilisearch_index).update_documents(
[
{
"id": str(quiz.id),
"title": quiz_input.title,
"description": quiz_input.description,
"user": (await User.objects.filter(id=quiz.user_id).first()).username,
}
]
)
if quiz.public and not quiz_input.public:
print("removing from meilisearch")
meilisearch.index(settings.meilisearch_index).delete_document(str(quiz.id))
if not quiz.public and quiz_input.public:
meilisearch.index(settings.meilisearch_index).add_documents(
[
{
"id": str(quiz.id),
"title": quiz_input.title,
"description": quiz_input.description,
"user": (await User.objects.filter(id=quiz.user_id).first()).username,
}
]
)
quiz.title = quiz_input.title
quiz.public = quiz_input.public
quiz.description = quiz_input.description
quiz.updated_at = datetime.now()
quiz.questions = quiz_input.dict()["questions"]
return await quiz.update()
@@ -149,4 +198,5 @@ async def delete_quiz(quiz_id: str, user: User = Depends(get_current_user)):
pass
if len(pics_to_delete) != 0:
await storage.delete(pics_to_delete)
meilisearch.index(settings.meilisearch_index).delete_document(str(quiz.id))
return await quiz.delete()
+88
View File
@@ -0,0 +1,88 @@
import pydantic
from fastapi import APIRouter
from classquiz.config import settings, meilisearch
from uuid import UUID
from typing import Optional, List
settings = settings()
router = APIRouter()
class Hit(pydantic.BaseModel):
id: UUID
title: str
description: str
user: str
formatted: Optional["Hit"] = pydantic.Field(None, alias="_formatted")
class SearchResponse(pydantic.BaseModel):
hits: List[Hit] | list[None]
nbHits: int
exhaustiveNbHits: bool
query: str
limit: int
offset: int
processingTimeMs: int
class SearchData(pydantic.BaseModel):
q: str
offset: Optional[int] = 0
limit: Optional[int] = 20
filter: Optional[str] = None
facetsDistribution: Optional[list[str]] = None
attributesToRetrieve: Optional[list[str]] = ["*"]
attributesToCrop: Optional[list[str]] = None
cropLength: Optional[int] = 200
attributesToHighlight: Optional[list[str]] = None
matches: Optional[bool] = False
sort: Optional[list[str]] = None
@router.post("/", response_model=SearchResponse)
async def search(data: SearchData):
index = meilisearch.get_index(settings.meilisearch_index)
query = index.search(
data.q,
{
"offset": data.offset,
"limit": data.limit,
"filter": data.filter,
"cropLength": data.cropLength,
"matches": data.matches,
"facetsDistribution": data.facetsDistribution,
"attributesToRetrieve": data.attributesToRetrieve,
"attributesToCrop": data.attributesToCrop,
"sort": data.sort,
"attributesToHighlight": data.attributesToHighlight,
},
)
return SearchResponse(**query)
@router.get("/", response_model=SearchResponse)
async def search_get(
q: str,
offset: int = 0,
limit: int = 20,
filter: str | None = None, # skipcq: PYL-W0622
cropLength: int = 200,
matches: bool = False,
attributesToHighlight: Optional[str] = "*",
):
index = meilisearch.get_index(settings.meilisearch_index)
query = index.search(
q,
{
"offset": offset,
"limit": limit,
"filter": filter, # skipcq: PYL-W0622
"cropLength": cropLength,
"matches": matches,
"attributesToHighlight": [attributesToHighlight],
},
)
return SearchResponse(**query)
View File
+9 -1
View File
@@ -59,5 +59,13 @@ services:
- ./Caddyfile-docker:/etc/caddy/Caddyfile
ports:
- "8000:8080"
meilisearch:
image: getmeili/meilisearch:latest
restart: always
environment:
MEILI_NO_ANALYTICS: true
volumes:
- meilisearch-data:/data.ms
volumes:
data:
data:
meilisearch-data:
+2 -1
View File
@@ -50,9 +50,10 @@
"svelte": "^3.47.0",
"svelte-check": "^2.6.0",
"svelte-preprocess": "^4.10.5",
"sveltejs-tippy": "^3.0.0",
"svelte-tippy": "^1.3.2",
"swiper": "^8.1.0",
"tailwindcss": "^3.0.24",
"tippy.js": "^6.3.7",
"tslib": "^2.3.1",
"typescript": "~4.6",
"yup": "^0.32.11"
+18 -16
View File
@@ -38,9 +38,10 @@ specifiers:
svelte: ^3.47.0
svelte-check: ^2.6.0
svelte-preprocess: ^4.10.5
sveltejs-tippy: ^3.0.0
svelte-tippy: ^1.3.2
swiper: ^8.1.0
tailwindcss: ^3.0.24
tippy.js: ^6.3.7
tslib: ^2.3.1
typescript: ~4.6
yup: ^0.32.11
@@ -55,7 +56,7 @@ devDependencies:
'@sentry/tracing': 6.19.6
'@sveltejs/adapter-auto': 1.0.0-next.34
'@sveltejs/adapter-node': 1.0.0-next.73
'@sveltejs/kit': 1.0.0-next.311_sass@1.50.0+svelte@3.47.0
'@sveltejs/kit': 1.0.0-next.312_sass@1.50.0+svelte@3.47.0
'@tailwindcss/typography': 0.5.2_tailwindcss@3.0.24
'@types/cookie': 0.4.1
'@types/js-cookie': 3.0.1
@@ -85,9 +86,10 @@ devDependencies:
svelte: 3.47.0
svelte-check: 2.6.0_1b6d41eb44819e98e49be8cacdc96c52
svelte-preprocess: 4.10.5_f6bc70e70cf1a2fde5bc22fd7000ea23
sveltejs-tippy: 3.0.0
svelte-tippy: 1.3.2
swiper: 8.1.0
tailwindcss: 3.0.24
tippy.js: 6.3.7
tslib: 2.3.1
typescript: 4.6.3
yup: 0.32.11
@@ -392,10 +394,10 @@ packages:
esbuild: 0.14.36
dev: true
/@sveltejs/kit/1.0.0-next.311_sass@1.50.0+svelte@3.47.0:
/@sveltejs/kit/1.0.0-next.312_sass@1.50.0+svelte@3.47.0:
resolution:
{
integrity: sha512-OkRIi0XBXcs16J+H+6fgOeFYxKu05gamY08KHAQ5cohjymUODGWIwaIfwAuNkZAcEEr8Vh2ejp4UcaxNzD53lQ==
integrity: sha512-l07ct0FJQnt+y3/NSEBvHKDKVE/7syYQnFhCVZ6H+4LEYRtTNybFxUTC8jA3xpZwPqJLZz1X+Tt+Sa8ZMuVzDg==
}
engines: { node: '>=14.13' }
hasBin: true
@@ -3507,6 +3509,15 @@ packages:
typescript: 4.6.3
dev: true
/svelte-tippy/1.3.2:
resolution:
{
integrity: sha512-41f+85hwhKBRqX0UNYrgFsi34Kk/KDvUkIZXYANxkWoA2NTVTCZbUC2J8hRNZ4TRVxObTshoZRjK2co5+i6LMw==
}
dependencies:
tippy.js: 6.3.7
dev: true
/svelte/3.47.0:
resolution:
{
@@ -3515,15 +3526,6 @@ packages:
engines: { node: '>= 8' }
dev: true
/sveltejs-tippy/3.0.0:
resolution:
{
integrity: sha512-LAfQikm61AeqZW1hCFWUsmGEMmhMHDvrPV0JhbT38Ell9SqBjmFaY//+YKUxaJvx6nLMxGQdlcHA1rDg2Ml8tQ==
}
dependencies:
tippy.js: 6.0.3
dev: true
/svgo/2.8.0:
resolution:
{
@@ -3600,10 +3602,10 @@ packages:
globrex: 0.1.2
dev: true
/tippy.js/6.0.3:
/tippy.js/6.3.7:
resolution:
{
integrity: sha512-buQF6HugTA4YeSA/s9xBhu0ferEAx7sRSn45G+Juh3p+Dz3vEKfxtqdg6JUnHIWDCV/r0u8Lrobs9AB4dtYcUQ==
integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
}
dependencies:
'@popperjs/core': 2.11.5
-44
View File
@@ -1,44 +0,0 @@
/// <reference types="@sveltejs/kit" />
// See https://kit.svelte.dev/docs/types#the-app-namespace
// for information about these interfaces
declare namespace App {
// interface Locals {}
// interface Platform {}
interface Session {
authenticated: boolean;
token: string | null;
email: string | null;
}
// interface Stuff {}
}
export interface QuizData {
title: string;
description: string;
quiz_id: string;
questions: Question[];
game_id: string;
game_pin: string;
started: boolean;
}
export interface Question {
time: string;
question: string;
image?: string;
answers: Answer[];
}
export interface Answer {
right: boolean;
answer: string;
}
// TODO Keep an eye on this shit
// export interface Answer {
// username: string;
// answer: string;
// right: boolean;
// }
+59
View File
@@ -0,0 +1,59 @@
<script>
export let headerText;
let expanded = false;
</script>
<div class="collapsible">
<h3>
<button aria-expanded={expanded} on:click={() => (expanded = !expanded)}
>{headerText}
<svg viewBox="0 0 20 20" fill="none">
<path class="vert" d="M10 1V19" stroke="black" stroke-width="2" />
<path d="M1 10L19 10" stroke="black" stroke-width="2" />
</svg>
</button>
</h3>
<div class="contents" class:hidden={!expanded}>
<slot />
</div>
</div>
<style>
.collapsible {
border-bottom: 1px solid var(--gray-light, #eee);
}
h3 {
margin: 0;
}
button {
background-color: var(--background, #fff);
color: var(--gray-darkest, #282828);
display: flex;
justify-content: space-between;
width: 100%;
border: none;
margin: 0;
padding: 1em 0.5em;
}
button[aria-expanded='true'] {
border-bottom: 1px solid var(--gray-light, #eee);
}
button[aria-expanded='true'] .vert {
display: none;
}
button:focus svg {
outline: 2px solid;
}
svg {
height: 0.7em;
width: 0.7em;
}
</style>
+22 -7
View File
@@ -94,21 +94,22 @@
<li class="py-2">
<a
class="text-lg font-medium lg:px-4 text-gray-600 hover:text-green-600 link-hover"
href="https://github.com/mawoka-myblock/classquiz">GitHub</a
href="/explore">Explore</a
>
</li>
<li class="py-2">
<a
class="text-lg font-medium lg:px-4 text-gray-600 hover:text-green-600 link-hover"
href="/search">Search</a
>
<!--TODO: Add translation -->
</li>
<li class="py-2">
<a
class="text-lg font-medium lg:px-4 text-gray-600 hover:text-green-600 link-hover"
href="/api/v1/users/logout">{$t('words.logout')}</a
>
</li>
<li class="py-2">
<a
class="text-lg font-medium lg:px-4 text-gray-600 hover:text-green-600 link-hover"
href="/docs">{$t('words.docs')}</a
>
</li>
{:else}
<li class="py-2">
<a
@@ -122,12 +123,26 @@
href="/account/register">{$t('words.register')}</a
>
</li>
<li class="py-2">
<a
class="text-lg font-medium lg:px-4 text-gray-600 hover:text-green-600 link-hover"
href="/account/login">{$t('words.login')}</a
>
</li>
<li class="py-2">
<a
class="text-lg font-medium lg:px-4 text-gray-600 hover:text-green-600 link-hover"
href="/explore">Explore</a
>
</li>
<li class="py-2">
<a
class="text-lg font-medium lg:px-4 text-gray-600 hover:text-green-600 link-hover"
href="/search">Search</a
>
<!--TODO: Add translation -->
</li>
<li class="py-2">
<a
class="text-lg font-medium lg:px-4 text-gray-600 hover:text-green-600 link-hover"
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { QuizData } from '../../app';
import type { QuizData } from '$lib/quiz_types';
export let quiz_data: QuizData;
export let final_results: Array<null> | Array<Array<PlayerAnswer>>;
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { Question } from '../../app';
import type { Question } from '$lib/quiz_types';
import { socket } from '$lib/socket';
export let question: Question;
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { Answer, QuizData } from '../../app';
import type { Answer, QuizData } from '$lib/quiz_types';
export let results: Array<Answer>;
export let game_data: QuizData;
+28
View File
@@ -0,0 +1,28 @@
export interface QuizData {
title: string;
description: string;
quiz_id: string;
questions: Question[];
game_id: string;
game_pin: string;
started: boolean;
}
export interface Question {
time: string;
question: string;
image?: string;
answers: Answer[];
}
export interface Answer {
right: boolean;
answer: string;
}
// TODO Keep an eye on this shit
// export interface Answer {
// username: string;
// answer: string;
// right: boolean;
// }
+10 -10
View File
@@ -23,7 +23,7 @@
</script>
<script lang="ts">
import type { QuizData } from '../app';
import type { QuizData } from '$lib/quiz_types';
import { socket } from '$lib/socket';
import { getLocalization } from '$lib/i18n';
@@ -205,15 +205,15 @@
{/each}
{/if}
</ul>
<!--{#if players.length > 0}-->
<button
on:click={() => {
socket.emit('start_game', '');
game_started = true;
}}
>{$t('admin_page.start_game')}
</button>
<!--{/if}-->
{#if players.length > 0}
<button
on:click={() => {
socket.emit('start_game', '');
game_started = true;
}}
>{$t('admin_page.start_game')}
</button>
{/if}
{:else}
{#if timer_res === undefined}
<span>Select a question to start!</span>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { QuizData } from '../app';
import type { QuizData } from '$lib/quiz_types';
import Title from '$lib/play/title.svelte';
import Question from '$lib/play/question.svelte';
import { socket } from '$lib/socket';
+4 -2
View File
@@ -21,6 +21,8 @@
<li><a href="https://caddyserver.com/">Caddy</a></li>
<li><a href="https://pipenv.pypa.io/en/latest/">Pipenv</a></li>
<li><a href="https://redis.io/">Redis</a></li>
<li><a href="https://meilisearch.com">Meilisearch</a></li>
<li><a href="https://caddyserver.com"><i>(Optional) Caddy</i></a></li>
</ul>
<h4>Frontend</h4>
@@ -60,8 +62,6 @@
</li>
</ul>
</li>
hcaptcha_key: str mail_address: str mail_password: str mail_username: str mail_server: str mail_port:
int secret_key: str
<li>
Set your config up in your .env-file. What you have to set up can you see in the
<code>classquiz/config.py</code>-file. The things you have to set are the following:
@@ -73,6 +73,7 @@
<li><code>mail_server</code></li>
<li><code>mail_port</code></li>
<li><code>secret_key</code></li>
<li><code>meilisearch_url</code></li>
</ul>
</li>
<li>
@@ -91,6 +92,7 @@
</ul>
<h2>BEFORE you submit a Pull-Request</h2>
<p>Please use <a href="https://gitmoji.dev">Gitmoji</a> for your commits.</p>
<h4>Frontend</h4>
<p>Please format and lint your code with</p>
<pre><code>pnpm run format && pnpm run lint</code></pre>
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
const getData = async () => {
const response = await fetch('/api/v1/search/?q=*');
return await response.json();
};
</script>
{#await getData()}
<svg class="h-8 w-8 animate-spin mx-auto my-20" viewBox="3 3 18 18">
<path
class="fill-black"
d="M12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5ZM3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"
/>
<path
class="fill-blue-100"
d="M16.9497 7.05015C14.2161 4.31648 9.78392 4.31648 7.05025 7.05015C6.65973 7.44067 6.02656 7.44067 5.63604 7.05015C5.24551 6.65962 5.24551 6.02646 5.63604 5.63593C9.15076 2.12121 14.8492 2.12121 18.364 5.63593C18.7545 6.02646 18.7545 6.65962 18.364 7.05015C17.9734 7.44067 17.3403 7.44067 16.9497 7.05015Z"
/>
</svg>
{:then quizzes}
<div class="grid grid-cols-3">
{#each quizzes.hits as quiz}
<div class="flex justify-center">
<a href="/view/{quiz.id}" class="h-max w-fit">
<div class="max-w-md py-4 px-8 bg-white shadow-lg rounded-lg my-20">
<!-- <div class='flex justify-center md:justify-end -mt-16'>
<img class='w-20 h-20 object-cover rounded-full border-2 border-indigo-500'
src='https://images.unsplash.com/photo-1499714608240-22fc6ad53fb2?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=334&q=80'>
</div>-->
<div>
<h2 class="text-gray-800 text-3xl font-semibold">{quiz.title}</h2>
<p class="mt-2 text-gray-600">{quiz.description}</p>
</div>
<div class="flex mt-4">
<span>Made by {quiz.user}</span>
</div>
</div>
</a>
</div>
{/each}
</div>
{/await}
+1 -1
View File
@@ -15,7 +15,7 @@
</script>
<script lang="ts">
import type { Question } from '../app';
import type { Question } from '$lib/quiz_types';
import { DateTime } from 'luxon';
import { getLocalization } from '$lib/i18n';
import Footer from '$lib/footer.svelte';
+1 -1
View File
@@ -17,7 +17,7 @@
<script lang="ts">
import { socket } from '$lib/socket';
import JoinGame from '$lib/play/join.svelte';
import type { Answer, QuizData } from '../app';
import type { Answer, QuizData } from '$lib/quiz_types';
import ShowTitle from '$lib/play/title.svelte';
import Question from '$lib/play/question.svelte';
import ShowResults from '$lib/play/show_results.svelte';
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
let search_term = '';
let resp_data = null;
const submit = async () => {
console.log('Submit!', search_term);
const res = await fetch('/api/v1/search/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
q: search_term,
attributesToHighlight: ['*']
})
});
if (res.status === 200) {
let resp_data_temp: string = await res.text();
resp_data_temp = resp_data_temp.replaceAll('<em>', '<mark>');
resp_data_temp = resp_data_temp.replaceAll('</em>', '</mark>');
resp_data = JSON.parse(resp_data_temp);
console.log('Success!', resp_data);
} else {
console.error('Error!', res.status);
}
};
</script>
<div>
<div class="flex justify-center">
<div class="mb-3 xl:w-96">
<form
class="input-group relative flex items-stretch flex-row w-full mb-4"
on:submit|preventDefault={submit}
>
<input
type="search"
class="form-control relative flex-auto min-w-0 block w-full px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none"
placeholder="Search"
aria-label="Search"
aria-describedby="button-addon2"
bind:value={search_term}
/>
<button
class="inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
id="button-addon2"
disabled={search_term.length <= 2}
type="submit"
>
<svg
aria-hidden="true"
focusable="false"
data-prefix="fas"
data-icon="search"
class="w-4"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
>
<path
fill="currentColor"
d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"
/>
</svg>
</button>
</form>
</div>
</div>
</div>
{#if resp_data}
<div class="grid grid-cols-3">
{#each resp_data.hits as quiz}
<div class="flex justify-center">
<a href="/view/{quiz.id}" class="h-max w-fit">
<div class="max-w-md py-4 px-8 bg-white shadow-lg rounded-lg my-20">
<!-- <div class='flex justify-center md:justify-end -mt-16'>
<img class='w-20 h-20 object-cover rounded-full border-2 border-indigo-500'
src='https://images.unsplash.com/photo-1499714608240-22fc6ad53fb2?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=334&q=80'>
</div>-->
<div>
<h2 class="text-gray-800 text-3xl font-semibold">
{@html quiz._formatted.title}
</h2>
<p class="mt-2 text-gray-600">{@html quiz._formatted.description}</p>
</div>
<div class="flex mt-4">
<span>Made by {@html quiz._formatted.user}</span>
</div>
</div>
</a>
</div>
{/each}
</div>
{/if}
<style lang="scss">
/* eslint-disable-next-line css-unused-selector */
mark {
background-color: #ff0;
border-radius: 0.2em;
color: #000;
font-style: normal;
font-weight: bold;
padding: 0 0.2em;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts" context="module">
export async function load({ params, fetch, session }) {
const { quiz_id } = params;
const res = await fetch(`/api/v1/quiz/get/public/${quiz_id}`);
if (res.status === 404) {
return {
status: 404
};
} else if (res.status === 200) {
const quiz = await res.json();
return {
props: {
quiz: quiz,
logged_in: session.authenticated
}
};
} else {
return {
status: 500
};
}
}
</script>
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import CollapsSection from '$lib/collapsible.svelte';
import { createTippy } from 'svelte-tippy';
import 'tippy.js/animations/perspective-subtle.css';
import 'tippy.js/dist/tippy.css';
const tippy = createTippy({
arrow: true,
animation: 'perspective-subtle',
placement: 'right'
});
const { t } = getLocalization();
export let logged_in: boolean;
export let quiz: QuizData;
interface Question {
time: string;
question: string;
image?: string;
answers: Answer[];
}
interface Answer {
right: boolean;
answer: string;
}
interface QuizData {
id: string;
public: boolean;
title: string;
description: string;
created_at: string;
updated_at: string;
user_id: string;
questions: Question[];
}
const startGame = async (id: string): Promise<void> => {
console.log('start game', id);
const res = await fetch(`/api/v1/quiz/start/${id}`, {
method: 'POST'
// headers: {
// 'Content-Type': 'application/json'
// }
});
if (res.status !== 200) {
throw new Error('Failed to start game');
}
const data = await res.json();
// eslint-disable-next-line no-undef
plausible('Started Game', { props: { quiz_id: id } });
window.location.replace(`/admin?token=${data.game_id}&pin=${data.game_pin}&connect=1`);
};
</script>
<div>
<h1 class="text-4xl text-center">{quiz.title}</h1>
<div class="text-center">
<p>{quiz.description}</p>
</div>
<div class="flex justify-center m-8">
{#if logged_in}
<button
class="px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded text-center hover:bg-gray-600 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
on:click={() => {
startGame(quiz.id);
}}
>
{$t('words.start')}
</button>
{:else}
<button
class="px-4 py-2 leading-5 text-white transition-colors duration-200 transform bg-gray-700 rounded text-center hover:bg-gray-600 focus:outline-none cursor-not-allowed opacity-50"
use:tippy={{ content: 'You need to be logged in to start a game' }}
>
{$t('words.start')}
</button>
{/if}
</div>
{#each quiz.questions as question, index_question}
<div class="px-4 py-1">
<CollapsSection headerText={question.question}>
<div class="ml-8 grid grid-cols-1 gap-2 m-2 border border-black border-2">
<h1 class="text-3xl m-1">{$t('words.question')} {index_question + 1}</h1>
<!-- <label class='m-1 flex flex-row gap-2 w-3/5'>-->
<p class="text-black w-full bg-inherit">
{$t('words.question')}: {question.question}
</p>
<!-- </label>-->
{#if question.image}
<span>
{$t('words.image')}:
<img class="pl-8" src={question.image} alt="Not provided" />
</span>
{/if}
<span class="m-1 flex flex-row gap-2 w-3/5 flex-nowrap whitespace-nowrap">
{$t('editor.time_in_seconds')}:
<p>{question.time}</p>
</span>
{#each question.answers as answer, index_answer}
<div
class="ml-8 grid grid-cols-1 gap-2 m-2 border border-black border-2 m-1"
>
<h1 class="text-3xl m-1">{$t('words.answer')} {index_answer + 1}</h1>
<p class="m-1">
{$t('words.answer')}: {index_answer + 1}
{$t('words.question')}: {index_question + 1}
</p>
<p>
{$t('words.answer')}
: {quiz.questions[index_question].answers[index_answer].answer}
</p>
<label
class="m-1 flex flex-row gap-2 w-2/6 flex-nowrap whitespace-nowrap"
>
<input
type="checkbox"
bind:checked={answer.right}
class="text-black w-fit"
disabled
/>
<span class="w-fit">{$t('editor.right_or_true?')}</span>
</label>
</div>
{/each}
</div>
</CollapsSection>
</div>
{/each}
</div>
+27
View File
@@ -0,0 +1,27 @@
import meilisearch
from classquiz.db.models import Quiz, User
from classquiz.config import settings
from asyncio import run
settings = settings()
async def __main__():
meili_data = []
questions = await Quiz.objects.filter(public=True).all()
for question in questions:
meili_data.append(
{
"id": str(question.id),
"title": question.title,
"description": question.description,
"user": (await User.objects.filter(id=question.user_id).first()).username,
}
)
print(meili_data)
client = meilisearch.Client(settings.meilisearch_url)
client.index(settings.meilisearch_index).add_documents(meili_data)
if __name__ == "__main__":
run(__main__())