diff --git a/.gitignore b/.gitignore index 589faa0..0fc9869 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /filestore +/static __pycache__ .idea venv -*.sqlite3 \ No newline at end of file +*.sqlite3 diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..b95d5c7 --- /dev/null +++ b/api/urls.py @@ -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/', 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'), +] diff --git a/content/admin.py b/content/admin.py index 5ee7941..d42e702 100644 --- a/content/admin.py +++ b/content/admin.py @@ -1,9 +1,14 @@ from django.contrib import admin from django.utils.safestring import mark_safe -from content.models import Link, MediaFile, Question, QuestionVersion, Level, Label -from lib.utils import color_label +from content.models import Link, MediaFile, Question, QuestionVersion, Level, Label, SharedQuestion 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) @@ -22,15 +27,19 @@ class LabelAdmin(PermissionsAdminMixin, admin.ModelAdmin): @admin.register(Question) class QuestionAdmin(PermissionsAdminMixin, admin.ModelAdmin): - autocomplete_fields = ('medias', 'links', 'labels',) + autocomplete_fields = ('medias', 'links', 'labels', 'shares') list_display = ('name', 'list_labels', 'list_level', 'author') search_fields = ('name', 'question', 'awnser', 'description', 'label__name', 'level__value', 'level__name',) def list_labels(self, instance): - return mark_safe(', '.join([color_label(l, value=l.name) for l in instance.labels.all()])) + if instance.labels: + return mark_safe(', '.join([color_label(l, value=l.name) for l in instance.labels.all()])) + return '' def list_level(self, instance): - return mark_safe(color_label(instance.level, value=str(instance.level))) + if instance.level: + return mark_safe(color_label(instance.level, value=str(instance.level))) + return '' list_level.short_description = 'Level' list_labels.short_description = 'Labels' diff --git a/content/models.py b/content/models.py index dfd2195..a04b8e7 100644 --- a/content/models.py +++ b/content/models.py @@ -10,18 +10,14 @@ from django.forms.models import model_to_dict from django.utils.safestring import mark_safe 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): - name = models.CharField(max_length=25, unique=True, db_index=True) +class MediaFile(NameAware, DateAware, AuthorAware, DescriptionAware): # https://django-filer.readthedocs.io/en/latest/extending_filer.html # https://pypi.org/project/django-thumbnails/ 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): return self.name @@ -30,13 +26,8 @@ class MediaFile(models.Model): return self.file.name -class Link(models.Model): - name = models.CharField(max_length=25, null=True, blank=True, db_index=True) +class Link(NameAware, DateAware, AuthorAware, DescriptionAware): 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): return self.url @@ -45,14 +36,9 @@ class Link(models.Model): return self.url -class Level(models.Model): - name = models.CharField(max_length=25, unique=True, db_index=True) +class Level(NameAware, DateAware, AuthorAware, DescriptionAware): value = models.IntegerField(unique=True, db_index=True) - description = models.TextField(null=True, blank=True) 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): return f'{self.value} - {self.name}' @@ -61,13 +47,8 @@ class Level(models.Model): return f'{self.value}' -class Label(models.Model): - name = models.CharField(max_length=25, unique=True, db_index=True) - description = models.TextField(null=True, blank=True) +class Label(NameAware, DateAware, AuthorAware, DescriptionAware): 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): return self.name @@ -76,19 +57,21 @@ class Label(models.Model): 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) question = models.TextField(db_index=True) buzzword = models.CharField(max_length=25, null=True, blank=True) awnser = models.TextField(db_index=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) medias = models.ManyToManyField(MediaFile, blank=True) links = models.ManyToManyField(Link, blank=True) - 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) + shares = models.ManyToManyField(SharedQuestion, blank=True) def __str__(self): return self.name @@ -96,23 +79,38 @@ class Question(models.Model): def __repr__(self): return self.name - def to_view(self, for_players: bool = False): - ret = {} - ret['name'] = '{}{}'.format(self.name, f'({self.buzzword})' if self.buzzword else '') - ret['question'] = self.question - if self.description: - ret['question'] += ' \n\n------------------------------\n\n' + self.description - ret['awnser'] = self.awnser - ret['level'] = self.level.value - ret['medias'] = [] - ret['links'] = [] - for media in self.medias.all(): - ret.setdefault('medias', []).append(os.path.basename(media.file.path)) - for link in self.links.all(): - ret.setdefault('links', []).append(link.url) - ret['question'] = mark_safe(ret['question'].replace('\n', '\n
')) - logging.error(ret) - return ret + @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['name'] = '{}{}'.format(self.name, f'({self.buzzword})' if self.buzzword else '') + ret['question'] = self.question + if self.description: + ret['question'] += ' \n\n------------------------------\n\n' + self.description + ret['awnser'] = self.awnser + ret['level'] = self.level.value + ret['medias'] = [] + ret['links'] = [] + for media in self.medias.all(): + ret.setdefault('medias', []).append(os.path.basename(media.file.path)) + for link in self.links.all(): + ret.setdefault('links', []).append(link.url) + ret['question'] = mark_safe(ret['question'].replace('\n', '\n
')) + logging.error(ret) + return ret def default_json(o): @@ -121,12 +119,9 @@ def default_json(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) 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): 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['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['shares'] = list(map(str, data['shares'])) QuestionVersion.objects.create( question=instance, diff --git a/content/serializers.py b/content/serializers.py new file mode 100644 index 0000000..a382b80 --- /dev/null +++ b/content/serializers.py @@ -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 + """ diff --git a/content/templates/questions.html b/content/templates/questions.html new file mode 100644 index 0000000..d7ab33e --- /dev/null +++ b/content/templates/questions.html @@ -0,0 +1,43 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+
+
+ {% csrf_token %} + + + + +
+
+
+
+
+
+ {% if items %} +
+
#
+
Name
+
Question
+
Files
+
Labels
+
+ {% for q in items %} +
+
{{ q.id }}
+
{{ q.name }}
+
{{ q.question }}
+
{{ q.medias.all | length }}
+
+ {% for l in q.labels.all %} + {{ l.name }} + {% if not forloop.last %},{% endif %} + {% endfor %} +
+
+ {% endfor %} + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/content/urls.py b/content/urls.py new file mode 100644 index 0000000..271c169 --- /dev/null +++ b/content/urls.py @@ -0,0 +1,6 @@ +from django.urls.conf import path +from content.views import public + +urlpatterns = [ + path('questions/', public.search_question, name='search-questions'), +] diff --git a/content/views.py b/content/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/content/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/content/views/__init__.py b/content/views/__init__.py new file mode 100644 index 0000000..bcbee5c --- /dev/null +++ b/content/views/__init__.py @@ -0,0 +1 @@ +from .api import QuestionViewSet \ No newline at end of file diff --git a/content/views/api.py b/content/views/api.py new file mode 100644 index 0000000..fc273af --- /dev/null +++ b/content/views/api.py @@ -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 diff --git a/content/views/public.py b/content/views/public.py new file mode 100644 index 0000000..9bcebc3 --- /dev/null +++ b/content/views/public.py @@ -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}) diff --git a/lib/api/__init__.py b/lib/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/api/permissions.py b/lib/api/permissions.py new file mode 100644 index 0000000..9be651d --- /dev/null +++ b/lib/api/permissions.py @@ -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 diff --git a/lib/core/db/__init__.py b/lib/core/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/db/models/__init__.py b/lib/core/db/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/db/models/base.py b/lib/core/db/models/base.py new file mode 100644 index 0000000..ffafad8 --- /dev/null +++ b/lib/core/db/models/base.py @@ -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)})' diff --git a/lib/core/db/models/mixins.py b/lib/core/db/models/mixins.py new file mode 100644 index 0000000..29fcacc --- /dev/null +++ b/lib/core/db/models/mixins.py @@ -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) + + diff --git a/quiz/models.py b/quiz/models.py index 3c98eb1..70ce8d4 100644 --- a/quiz/models.py +++ b/quiz/models.py @@ -13,13 +13,15 @@ from django.utils.safestring import mark_safe from content.models import Question as QuestionContent from lib import get_current_user 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) - 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) + # 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): return self.name @@ -117,14 +119,12 @@ class Quiz(models.Model): 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') quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE) points = models.DecimalField(max_digits=10, decimal_places=1, default=1.0) order = models.IntegerField(default=1) 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): return str(self.question) diff --git a/requirements.txt b/requirements.txt index 7ac0d1b..7d762c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,7 @@ django-colorfield django-thumbnails matplotlib django-jazzmin -requests \ No newline at end of file +requests +django-rest-swagger +drf-yasg +django-guardian diff --git a/tablequizwiki/settings.py b/tablequizwiki/settings.py index 477f0d5..b4a2a68 100644 --- a/tablequizwiki/settings.py +++ b/tablequizwiki/settings.py @@ -40,6 +40,9 @@ INSTALLED_APPS = [ 'easy_thumbnails', 'colorfield', 'filer', + 'rest_framework_swagger', + # 'guardian', + 'api', 'content', 'quiz', ] @@ -103,6 +106,12 @@ AUTH_PASSWORD_VALIDATORS = [ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] +""" +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', # this is default + 'guardian.backends.ObjectPermissionBackend', +) +""" # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ @@ -118,6 +127,7 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ +STATICFILES_DIRS = [BASE_DIR / 'static'] STATIC_URL = 'static/' # Default primary key field type @@ -187,3 +197,5 @@ THUMBNAILS = { # } } } + +REST_FRAMEWORK = {'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema'} diff --git a/tablequizwiki/urls.py b/tablequizwiki/urls.py index a09c118..a1348a0 100644 --- a/tablequizwiki/urls.py +++ b/tablequizwiki/urls.py @@ -18,8 +18,12 @@ from django.contrib import admin from django.urls import path, include import quiz.urls as quizurls +import api.urls as apiurls +import content.urls as contenturls urlpatterns = [ path('admin/', admin.site.urls), - path('quiz/', include(quizurls)) + path('quiz/', include(quizurls)), + path('api/', include(apiurls)), + path('', include(contenturls)) ] diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..c846369 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,41 @@ + + + + + TablequizDB + + + + + + + + {# #} + + +
+
+

Tablequiz DB

+ +
+ {% block content %} + {% endblock %} + +
+ + + \ No newline at end of file