added first public views - no check for access rights at the moment

This commit is contained in:
Holger Sielaff
2024-07-12 17:22:17 +02:00
parent 7d53df6ea5
commit 3640ab759d
22 changed files with 386 additions and 69 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/filestore /filestore
/static
__pycache__ __pycache__
.idea .idea
venv venv

35
api/urls.py Normal file
View File

@@ -0,0 +1,35 @@
from django.urls.conf import path
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import permissions
# from rest_framework_swagger.views import get_swagger_view
# from rest_framework.routers import DefaultRouter
# from content.views import QuestionViewSet
# schema_view = get_swagger_view(title='TablequizDB API')
# router = DefaultRouter()
# router.register(r'question', QuestionViewSet)
# urlpatterns = [
# path('docs/', schema_view, name='api-v1'),
# path('v1/', include(router.urls)),
# ]
schema_view = get_schema_view(
openapi.Info(
title="TabelquizDB API",
default_version='v1',
description="The API",
# terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="api@tablequizdb.de"),
license=openapi.License(name="BSD License"),
),
public=True,
permission_classes=(permissions.AllowAny,),
)
urlpatterns = [
path('swagger<format>/', schema_view.without_ui(cache_timeout=0), name='schema-json'),
path('', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
]

View File

@@ -1,9 +1,14 @@
from django.contrib import admin from django.contrib import admin
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from content.models import Link, MediaFile, Question, QuestionVersion, Level, Label from content.models import Link, MediaFile, Question, QuestionVersion, Level, Label, SharedQuestion
from lib.utils import color_label
from lib.mixins import PermissionsAdminMixin from lib.mixins import PermissionsAdminMixin
from lib.utils import color_label
@admin.register(SharedQuestion)
class SharedQuestionAdmin(admin.ModelAdmin):
search_fields = ('user__username',)
@admin.register(Level) @admin.register(Level)
@@ -22,15 +27,19 @@ class LabelAdmin(PermissionsAdminMixin, admin.ModelAdmin):
@admin.register(Question) @admin.register(Question)
class QuestionAdmin(PermissionsAdminMixin, admin.ModelAdmin): class QuestionAdmin(PermissionsAdminMixin, admin.ModelAdmin):
autocomplete_fields = ('medias', 'links', 'labels',) autocomplete_fields = ('medias', 'links', 'labels', 'shares')
list_display = ('name', 'list_labels', 'list_level', 'author') list_display = ('name', 'list_labels', 'list_level', 'author')
search_fields = ('name', 'question', 'awnser', 'description', 'label__name', 'level__value', 'level__name',) search_fields = ('name', 'question', 'awnser', 'description', 'label__name', 'level__value', 'level__name',)
def list_labels(self, instance): def list_labels(self, instance):
if instance.labels:
return mark_safe(', '.join([color_label(l, value=l.name) for l in instance.labels.all()])) return mark_safe(', '.join([color_label(l, value=l.name) for l in instance.labels.all()]))
return ''
def list_level(self, instance): def list_level(self, instance):
if instance.level:
return mark_safe(color_label(instance.level, value=str(instance.level))) return mark_safe(color_label(instance.level, value=str(instance.level)))
return ''
list_level.short_description = 'Level' list_level.short_description = 'Level'
list_labels.short_description = 'Labels' list_labels.short_description = 'Labels'

View File

@@ -10,18 +10,14 @@ from django.forms.models import model_to_dict
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from filer.fields.file import FilerFileField from filer.fields.file import FilerFileField
from lib import get_current_user from lib.core.db.models.base import SharedPermissionBase
from lib.core.db.models.mixins import DateAware, AuthorAware, DescriptionAware, NameAware
class MediaFile(models.Model): class MediaFile(NameAware, DateAware, AuthorAware, DescriptionAware):
name = models.CharField(max_length=25, unique=True, db_index=True)
# https://django-filer.readthedocs.io/en/latest/extending_filer.html # https://django-filer.readthedocs.io/en/latest/extending_filer.html
# https://pypi.org/project/django-thumbnails/ # https://pypi.org/project/django-thumbnails/
file = FilerFileField(on_delete=models.RESTRICT, related_name='media_file') file = FilerFileField(on_delete=models.RESTRICT, related_name='media_file')
description = models.TextField(null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, default=get_current_user)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -30,13 +26,8 @@ class MediaFile(models.Model):
return self.file.name return self.file.name
class Link(models.Model): class Link(NameAware, DateAware, AuthorAware, DescriptionAware):
name = models.CharField(max_length=25, null=True, blank=True, db_index=True)
url = models.URLField(unique=True, db_index=True) url = models.URLField(unique=True, db_index=True)
description = models.TextField(null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, default=get_current_user)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return self.url return self.url
@@ -45,14 +36,9 @@ class Link(models.Model):
return self.url return self.url
class Level(models.Model): class Level(NameAware, DateAware, AuthorAware, DescriptionAware):
name = models.CharField(max_length=25, unique=True, db_index=True)
value = models.IntegerField(unique=True, db_index=True) value = models.IntegerField(unique=True, db_index=True)
description = models.TextField(null=True, blank=True)
color = ColorField(default='#F90F90') color = ColorField(default='#F90F90')
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, default=get_current_user)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f'{self.value} - {self.name}' return f'{self.value} - {self.name}'
@@ -61,13 +47,8 @@ class Level(models.Model):
return f'{self.value}' return f'{self.value}'
class Label(models.Model): class Label(NameAware, DateAware, AuthorAware, DescriptionAware):
name = models.CharField(max_length=25, unique=True, db_index=True)
description = models.TextField(null=True, blank=True)
color = ColorField(default='#666666') color = ColorField(default='#666666')
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, default=get_current_user)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -76,19 +57,21 @@ class Label(models.Model):
return self.name return self.name
class Question(models.Model): class SharedQuestion(SharedPermissionBase):
class Meta:
abstract = False
class Question(DateAware, AuthorAware, DescriptionAware):
name = models.CharField(max_length=500, unique=True, db_index=True) name = models.CharField(max_length=500, unique=True, db_index=True)
question = models.TextField(db_index=True) question = models.TextField(db_index=True)
buzzword = models.CharField(max_length=25, null=True, blank=True) buzzword = models.CharField(max_length=25, null=True, blank=True)
awnser = models.TextField(db_index=True) awnser = models.TextField(db_index=True)
level = models.ForeignKey(Level, on_delete=models.SET_NULL, null=True) level = models.ForeignKey(Level, on_delete=models.SET_NULL, null=True)
description = models.TextField(null=True, blank=True)
labels = models.ManyToManyField(Label, blank=True) labels = models.ManyToManyField(Label, blank=True)
medias = models.ManyToManyField(MediaFile, blank=True) medias = models.ManyToManyField(MediaFile, blank=True)
links = models.ManyToManyField(Link, blank=True) links = models.ManyToManyField(Link, blank=True)
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) shares = models.ManyToManyField(SharedQuestion, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -96,7 +79,22 @@ class Question(models.Model):
def __repr__(self): def __repr__(self):
return self.name return self.name
def to_view(self, for_players: bool = False): @staticmethod
def searchdomain(term, from_quiz=False):
return \
models.Q(name__icontains=term) \
| models.Q(question__icontains=term) \
| models.Q(awnser__icontains=term) \
| models.Q(buzzword__icontains=term) \
| models.Q(labels__name__icontains=term)
@staticmethod
def get_by_tearchterm(term):
return Question.objects.filter(Question.searchdomain(term)).annotate(cnt=models.Count('id'))
def to_view(self, for_players: bool = False):
ret = {} ret = {}
ret['name'] = '{}{}'.format(self.name, f'({self.buzzword})' if self.buzzword else '') ret['name'] = '{}{}'.format(self.name, f'({self.buzzword})' if self.buzzword else '')
ret['question'] = self.question ret['question'] = self.question
@@ -121,12 +119,9 @@ def default_json(o):
return json.dumps(model_to_dict(o)) return json.dumps(model_to_dict(o))
class QuestionVersion(models.Model): class QuestionVersion(DateAware, AuthorAware):
question = models.ForeignKey(Question, on_delete=models.SET_NULL, null=True) question = models.ForeignKey(Question, on_delete=models.SET_NULL, null=True)
data = models.JSONField() data = models.JSONField()
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f'{self.question} - {self.created_at}' if self.question else f'{self.data.name} - {self.created_at}' return f'{self.question} - {self.created_at}' if self.question else f'{self.data.name} - {self.created_at}'
@@ -140,6 +135,7 @@ def versionize_question(sender, instance: Question, *args, **kwargs):
data['medias'] = [{'id': m.id, '__str__': str(m)} for m in data['medias']] data['medias'] = [{'id': m.id, '__str__': str(m)} for m in data['medias']]
data['links'] = [{'id': m.id, '__str__': str(m)} for m in data['links']] data['links'] = [{'id': m.id, '__str__': str(m)} for m in data['links']]
data['labels'] = [{'id': m.id, '__str__': str(m)} for m in data['labels']] data['labels'] = [{'id': m.id, '__str__': str(m)} for m in data['labels']]
data['shares'] = list(map(str, data['shares']))
QuestionVersion.objects.create( QuestionVersion.objects.create(
question=instance, question=instance,

29
content/serializers.py Normal file
View File

@@ -0,0 +1,29 @@
import logging
from rest_framework import serializers
from content.models import Question, Label, Level
class QuestionSerializer(serializers.Serializer):
# question = serializers.CharField()
# awnser = serializers.CharField()
# buzzword = serializers.CharField(required=False)
# level = serializers.IntegerField()
# labels = serializers.MultipleChoiceField(choices=[(l.id, l.name) for l in Label.objects.all()], required=False)
class Meta:
model = Question
# fields = ['id', 'name', 'description', 'level', 'labels', 'medias', ]
fields = '__all__'
"""
def create(self, validated_data):
return Question.objects.create(**validated_data)
def update(self, instance, validated_data):
instance.name = validated_data.get('name', instance.name)
# ...
instance.save()
return instance
"""

View File

@@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% block content %}
<form action="/questions" method="get" id="search_q_form">
<div id="question-search-field" class="row mb-5 mt-5">
<div class="col-12">
<div class="form-group">
<div class="input-group">
{% csrf_token %}
<input type="text" name="term" class="form-control p-2 bg-light" placeholder="Search for ..." value="{{ request.GET.term }}" />
<span class="input-group-text">
<i class="fa fa-search" onclick="document.getElementById('search_q_form').submit();"></i>
</span>
</div>
</div>
</div>
</div>
</form>
<div id="question-list" class="mt-2">
{% if items %}
<div class="row text-capitalize text-light bg-secondary border rounded p-2">
<div class="col-1">#</div>
<div class="col-3">Name</div>
<div class="col-3">Question</div>
<div class="col-1">Files</div>
<div class="col-1">Labels</div>
</div>
{% for q in items %}
<div class="row p-2">
<div class="col-1">{{ q.id }}</div>
<div class="col-3">{{ q.name }}</div>
<div class="col-3">{{ q.question }}</div>
<div class="col-1">{{ q.medias.all | length }}</div>
<div class="col-3">
{% for l in q.labels.all %}
{{ l.name }}
{% if not forloop.last %},{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}

6
content/urls.py Normal file
View File

@@ -0,0 +1,6 @@
from django.urls.conf import path
from content.views import public
urlpatterns = [
path('questions/', public.search_question, name='search-questions'),
]

View File

@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -0,0 +1 @@
from .api import QuestionViewSet

50
content/views/api.py Normal file
View File

@@ -0,0 +1,50 @@
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema, status
from rest_framework import viewsets
from content.models import Question
from content.serializers import QuestionSerializer
from lib.api.permissions import IsAuthorOrReadOnly
class QuestionViewSet(viewsets.ModelViewSet):
queryset = Question.objects.all()
serializer_class = QuestionSerializer
permission_classes = (
IsAuthorOrReadOnly,
)
@swagger_auto_schema(
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"question": openapi.Schema(
type=openapi.TYPE_STRING, description="Email Address"
),
"awnser": openapi.Schema(
type=openapi.TYPE_STRING, description="Previous Password"
),
"label": openapi.Schema(
type=openapi.TYPE_STRING, description="New Password"
),
},
),
responses={
status.HTTP_202_ACCEPTED: openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"status": openapi.Schema(type=openapi.TYPE_NUMBER),
"message": openapi.Schema(type=openapi.TYPE_STRING),
},
),
status.HTTP_401_UNAUTHORIZED: openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"status": openapi.Schema(type=openapi.TYPE_NUMBER),
"message": openapi.Schema(type=openapi.TYPE_STRING),
},
),
},
)
def create(self, request):
pass

13
content/views/public.py Normal file
View File

@@ -0,0 +1,13 @@
from django.shortcuts import render
from content.models import Question
def search_question(request):
term = request.GET.get('term')
items = []
if term:
items = Question.get_by_tearchterm(term)
else:
items = Question.objects.all()[:10]
return render(request, 'questions.html', {'items': items})

0
lib/api/__init__.py Normal file
View File

11
lib/api/permissions.py Normal file
View File

@@ -0,0 +1,11 @@
from rest_framework import permissions
class IsAuthorOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request
if request.method in permissions.SAFE_METHODS:
return True
return obj.author == request.user or request.user.is_superuser

0
lib/core/db/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,31 @@
from functools import cached_property
from django.contrib.auth.models import User
from django.db import models
from lib.core.db.models.mixins import DateAware
class SharedPermissionBase(DateAware, models.Model):
class Meta:
abstract = True
user = models.ForeignKey(User, on_delete=models.CASCADE)
view = models.BooleanField(default=True)
change = models.BooleanField(default=False)
delete = models.BooleanField(default=False)
add = models.BooleanField(default=False)
# item = models.IntegerField(default=NotImplementedError('Must be defined in inheriting class'))
@cached_property
def perms(self):
perms = filter(None, [
'read' if self.view else False,
'change' if self.change else False,
'delete' if self.delete else False,
'add' if self.add else False,
])
return list(perms)
def __str__(self):
return f'{self.user.username} ({", ".join(self.perms)})'

View File

@@ -0,0 +1,35 @@
from django.contrib.auth.models import User
from django.db import models
from lib.middleware.current_user import get_current_user
class DateAware(models.Model):
class Meta:
abstract = True
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class AuthorAware(models.Model):
class Meta:
abstract = True
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, default=get_current_user)
class DescriptionAware(models.Model):
class Meta:
abstract = True
description = models.TextField(null=True, blank=True)
class NameAware(models.Model):
class Meta:
abstract = True
name = models.CharField(max_length=100, unique=True, db_index=True)

View File

@@ -13,13 +13,15 @@ from django.utils.safestring import mark_safe
from content.models import Question as QuestionContent from content.models import Question as QuestionContent
from lib import get_current_user from lib import get_current_user
from tablequizwiki.settings import BASE_DIR from tablequizwiki.settings import BASE_DIR
from lib.core.db.models.mixins import AuthorAware, DateAware
class Quiz(models.Model): class Quiz(AuthorAware, DateAware):
name = models.CharField(max_length=250) name = models.CharField(max_length=250)
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, default=get_current_user) # author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, default=get_current_user)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) # created_at = models.DateTimeField(auto_now_add=True)
# updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -117,14 +119,12 @@ class Quiz(models.Model):
return str(BASE_DIR) + f'/filestore/csv/quiz.{self.id}.csv' return str(BASE_DIR) + f'/filestore/csv/quiz.{self.id}.csv'
class Question(models.Model): class Question(DateAware):
question = models.ForeignKey(QuestionContent, on_delete=models.RESTRICT, related_name='content_question') question = models.ForeignKey(QuestionContent, on_delete=models.RESTRICT, related_name='content_question')
quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE) quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE)
points = models.DecimalField(max_digits=10, decimal_places=1, default=1.0) points = models.DecimalField(max_digits=10, decimal_places=1, default=1.0)
order = models.IntegerField(default=1) order = models.IntegerField(default=1)
description = models.TextField(null=True, blank=True) description = models.TextField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return str(self.question) return str(self.question)

View File

@@ -13,3 +13,6 @@ django-thumbnails
matplotlib matplotlib
django-jazzmin django-jazzmin
requests requests
django-rest-swagger
drf-yasg
django-guardian

View File

@@ -40,6 +40,9 @@ INSTALLED_APPS = [
'easy_thumbnails', 'easy_thumbnails',
'colorfield', 'colorfield',
'filer', 'filer',
'rest_framework_swagger',
# 'guardian',
'api',
'content', 'content',
'quiz', 'quiz',
] ]
@@ -103,6 +106,12 @@ AUTH_PASSWORD_VALIDATORS = [
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
}, },
] ]
"""
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', # this is default
'guardian.backends.ObjectPermissionBackend',
)
"""
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/ # https://docs.djangoproject.com/en/4.2/topics/i18n/
@@ -118,6 +127,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/ # https://docs.djangoproject.com/en/4.2/howto/static-files/
STATICFILES_DIRS = [BASE_DIR / 'static']
STATIC_URL = 'static/' STATIC_URL = 'static/'
# Default primary key field type # Default primary key field type
@@ -187,3 +197,5 @@ THUMBNAILS = {
# } # }
} }
} }
REST_FRAMEWORK = {'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema'}

View File

@@ -18,8 +18,12 @@ from django.contrib import admin
from django.urls import path, include from django.urls import path, include
import quiz.urls as quizurls import quiz.urls as quizurls
import api.urls as apiurls
import content.urls as contenturls
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('quiz/', include(quizurls)) path('quiz/', include(quizurls)),
path('api/', include(apiurls)),
path('', include(contenturls))
] ]

41
templates/base.html Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>TablequizDB</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<style type="text/css">
.input-group.input-group-unstyled input.form-control {
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
}
.input-group-unstyled .input-group-addon {
border-radius: 4px;
border: 0px;
background-color: transparent;
}
</style>
<link href="https://ka-f.fontawesome.com/releases/v6.5.2/css/free.min.css?token=5f65fb5684" rel="stylesheet" />
<link href="https://ka-f.fontawesome.com/releases/v6.5.2/css/free-v4-shims.min.css?token=5f65fb5684" rel="stylesheet" />
<link href="https://ka-f.fontawesome.com/releases/v6.5.2/css/free-v5-font-face.min.css?token=5f65fb5684" rel="stylesheet" />
<link href="https://ka-f.fontawesome.com/releases/v6.5.2/css/free-v4-font-face.min.css?token=5f65fb5684" rel="stylesheet" />
{# <script src="https://kit.fontawesome.com/5f65fb5684.js" crossorigin="anonymous"></script> #}
</head>
<body>
<div class="container mt-2">
<header>
<h1>Tablequiz DB</h1>
<nav>
<a class="nav-item" href="/questions/">Questions</a>
<a class="nav-item" href="/quizes/">Quizes</a>
</nav>
</header>
{% block content %}
{% endblock %}
</div>
</body>
</html>