Merge pull request #724 from ae-utbm/ninja

Use django-ninja for the API
This commit is contained in:
thomas girod
2024-07-24 00:48:08 +02:00
committed by GitHub
60 changed files with 1455 additions and 1331 deletions

29
core/api.py Normal file
View File

@ -0,0 +1,29 @@
from django.conf import settings
from django.http import HttpResponse
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import PermissionDenied
from club.models import Mailing
from core.schemas import MarkdownSchema
from core.templatetags.renderer import markdown
@api_controller("/markdown")
class MarkdownController(ControllerBase):
@route.post("", url_name="markdown")
def render_markdown(self, body: MarkdownSchema):
"""Convert the markdown text into html."""
return HttpResponse(markdown(body.text), content_type="text/html")
@api_controller("/mailings")
class MailingListController(ControllerBase):
@route.get("", response=str)
def fetch_mailing_lists(self, key: str):
if key != settings.SITH_MAILING_FETCH_KEY:
raise PermissionDenied
mailings = Mailing.objects.filter(
is_moderated=True, club__is_active=True
).prefetch_related("subscriptions")
data = "\n".join(m.fetch_format() for m in mailings)
return data

122
core/api_permissions.py Normal file
View File

@ -0,0 +1,122 @@
"""Permission classes to be used within ninja-extra controllers.
Some permissions are global (like `IsInGroup` or `IsRoot`),
and some others are per-object (like `CanView` or `CanEdit`).
Examples:
# restrict all the routes of this controller
# to subscribed users
@api_controller("/foo", permissions=[IsSubscriber])
class FooController(ControllerBase):
@route.get("/bar")
def bar_get(self):
# This route inherits the permissions of the controller
# ...
@route.bar("/bar/{bar_id}", permissions=[CanView])
def bar_get_one(self, bar_id: int):
# per-object permission resolution happens
# when calling either the `get_object_or_exception`
# or `get_object_or_none` method.
bar = self.get_object_or_exception(Counter, pk=bar_id)
# you can also call the `check_object_permission` manually
other_bar = Counter.objects.first()
self.check_object_permissions(other_bar)
# ...
# This route is restricted to counter admins and root users
@route.delete(
"/bar/{bar_id}",
permissions=[IsRoot | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
]
def bar_delete(self, bar_id: int):
# ...
"""
from typing import Any
from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
class IsInGroup(BasePermission):
"""Check that the user is in the group whose primary key is given."""
def __init__(self, group_pk: int):
self._group_pk = group_pk
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return request.user.is_in_group(pk=self._group_pk)
class IsRoot(BasePermission):
"""Check that the user is root."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return request.user.is_root
class IsSubscriber(BasePermission):
"""Check that the user is currently subscribed."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return request.user.is_subscribed
class IsOldSubscriber(BasePermission):
"""Check that the user has at least one subscription in its history."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return request.user.was_subscribed
class CanView(BasePermission):
"""Check that this user has the permission to view the object of this route.
Wrap the `user.can_view(obj)` method.
To see an example, look at the exemple in the module docstring.
"""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return True
def has_object_permission(
self, request: HttpRequest, controller: ControllerBase, obj: Any
) -> bool:
return request.user.can_view(obj)
class CanEdit(BasePermission):
"""Check that this user has the permission to edit the object of this route.
Wrap the `user.can_edit(obj)` method.
To see an example, look at the exemple in the module docstring.
"""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return True
def has_object_permission(
self, request: HttpRequest, controller: ControllerBase, obj: Any
) -> bool:
return request.user.can_edit(obj)
class IsOwner(BasePermission):
"""Check that this user owns the object of this route.
Wrap the `user.is_owner(obj)` method.
To see an example, look at the exemple in the module docstring.
"""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return True
def has_object_permission(
self, request: HttpRequest, controller: ControllerBase, obj: Any
) -> bool:
return request.user.is_owner(obj)

32
core/baker_recipes.py Normal file
View File

@ -0,0 +1,32 @@
from datetime import timedelta
from django.utils.timezone import now
from model_bakery import seq
from model_bakery.recipe import Recipe, related
from core.models import User
from subscription.models import Subscription
active_subscription = Recipe(
Subscription,
subscription_start=now() - timedelta(days=30),
subscription_end=now() + timedelta(days=30),
)
ended_subscription = Recipe(
Subscription,
subscription_start=now() - timedelta(days=60),
subscription_end=now() - timedelta(days=30),
)
subscriber_user = Recipe(
User,
first_name="subscriber",
last_name=seq("user "),
subscriptions=related(active_subscription),
)
old_subscriber_user = Recipe(
User,
first_name="old subscriber",
last_name=seq("user "),
subscriptions=related(ended_subscription),
)

15
core/schemas.py Normal file
View File

@ -0,0 +1,15 @@
from ninja import ModelSchema, Schema
from core.models import User
class SimpleUserSchema(ModelSchema):
"""A schema with the minimum amount of information to represent a user."""
class Meta:
model = User
fields = ["id", "nick_name", "first_name", "last_name"]
class MarkdownSchema(Schema):
text: str

View File

@ -24,11 +24,6 @@ $black-color: hsl(0, 0%, 17%);
$faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%);
$pinktober: #ff5674;
$pinktober-secondary: #8a2536;
$pinktober-primary-text: white;
$pinktober-bar-closed: $pinktober-secondary;
$pinktober-bar-opened: #388e3c;
$shadow-color: rgb(223, 223, 223);
@ -48,6 +43,18 @@ body {
font-family: sans-serif;
}
button:disabled,
button:disabled:hover {
color: #fff;
background-color: #6c757d;
}
button.active,
button.active:hover {
color: #fff;
background-color: $secondary-color;
}
a.button,
button,
input[type="button"],
@ -1510,6 +1517,10 @@ $pedagogy-light-blue: #caf0ff;
$pedagogy-white-text: #f0f0f0;
.pedagogy {
#pagination {
text-align: center;
}
&.star-not-checked {
color: #f7f7f7;
margin-bottom: 0;

View File

@ -14,26 +14,21 @@
document.head.innerHTML += '<link rel="stylesheet" href="' + css + '">';
}
// Custom markdown parser
function customMarkdownParser(plainText, cb) {
$.ajax({
url: "{{ markdown_api_url }}",
method: "POST",
data: { text: plainText, csrfmiddlewaretoken: getCSRFToken() },
}).done(cb);
}
// Pretty markdown input
const easymde = new EasyMDE({
element: document.getElementById("{{ widget.attrs.id }}"),
spellChecker: false,
autoDownloadFontAwesome: false,
previewRender: function(plainText, preview) { // Async method
previewRender: function (plainText, preview) {
clearTimeout(lastAPICall);
lastAPICall = setTimeout(() => {
customMarkdownParser(plainText, (msg) => preview.innerHTML = msg);
lastAPICall = setTimeout(async () => {
const res = await fetch("{{ markdown_api_url }}", {
method: "POST",
body: JSON.stringify({ text: plainText }),
});
preview.innerHTML = await res.text();
}, 300);
return preview.innerHTML;
return null;
},
forceSync: true, // Avoid validation error on generic create view
toolbar: [

View File

@ -84,10 +84,10 @@
}
function download_pictures() {
$("#download_all_pictures").prop("disabled", true);
var xhr = new XMLHttpRequest();
const xhr = new XMLHttpRequest();
$.ajax({
type: "GET",
url: "{{ url('api:all_pictures_of_user', user=object.id) }}",
url: "{{ url('api:pictures') }}?users_identified={{ object.id }}",
tryCount: 0,
xhr: function(){
return xhr;

View File

@ -65,7 +65,7 @@ class TestUserRegistration:
{"password2": "not the same as password1"},
"Les deux mots de passe ne correspondent pas.",
),
({"email": "not-an-email"}, "Saisissez une adresse e-mail valide."),
({"email": "not-an-email"}, "Saisissez une adresse de courriel valide."),
({"first_name": ""}, "Ce champ est obligatoire."),
({"last_name": ""}, "Ce champ est obligatoire."),
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
@ -217,7 +217,7 @@ def test_full_markdown_syntax():
assert result == html
class PageHandlingTest(TestCase):
class TestPageHandling(TestCase):
@classmethod
def setUpTestData(cls):
cls.root = User.objects.get(username="root")
@ -310,7 +310,6 @@ http://git.an
)
response = self.client.get(reverse("core:page", kwargs={"page_name": "guy"}))
assert response.status_code == 200
print(response.content.decode())
expected = """
<p>Guy <em>bibou</em></p>
<p><a href="http://git.an">http://git.an</a></p>
@ -321,11 +320,16 @@ http://git.an
assertInHTML(expected, response.content.decode())
class UserToolsTest:
@pytest.mark.django_db
class TestUserTools:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to the tools page."""
response = client.get(reverse("core:user_tools"))
assert response.status_code == 403
assertRedirects(
response,
expected_url=f"/login?next=%2Fuser%2Ftools%2F",
target_status_code=301,
)
@pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"])
def test_page_is_working(self, client, username):
@ -336,13 +340,47 @@ class UserToolsTest:
assert response.status_code == 200
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages:
# - renaming a page
# - changing a page's parent --> check that page's children's full_name
# - changing the different groups of the page
class FileHandlingTest(TestCase):
class TestFileHandling(TestCase):
@classmethod
def setUpTestData(cls):
cls.subscriber = User.objects.get(username="subscriber")
@ -378,7 +416,7 @@ class FileHandlingTest(TestCase):
assert "ls</a>" in str(response.content)
class UserIsInGroupTest(TestCase):
class TestUserIsInGroup(TestCase):
"""Test that the User.is_in_group() and AnonymousUser.is_in_group()
work as intended.
"""
@ -519,7 +557,7 @@ class UserIsInGroupTest(TestCase):
assert self.skia.is_in_group(name="This doesn't exist") is False
class DateUtilsTest(TestCase):
class TestDateUtils(TestCase):
@classmethod
def setUpTestData(cls):
cls.autumn_month = settings.SITH_SEMESTER_START_AUTUMN[0]

View File

@ -25,6 +25,7 @@
import types
from typing import Any
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import (
ImproperlyConfigured,
PermissionDenied,
@ -234,7 +235,7 @@ class UserIsRootMixin(GenericContentPermissionMixinBuilder):
permission_function = lambda obj, user: user.is_root
class FormerSubscriberMixin(View):
class FormerSubscriberMixin(AccessMixin):
"""Check if the user was at least an old subscriber.
Raises:
@ -247,16 +248,10 @@ class FormerSubscriberMixin(View):
return super().dispatch(request, *args, **kwargs)
class UserIsLoggedMixin(View):
"""Check if the user is logged.
Raises:
PermissionDenied:
"""
class SubscriberMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if request.user.is_anonymous:
raise PermissionDenied
if not request.user.is_subscribed:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)

View File

@ -104,7 +104,7 @@ class MarkdownInput(Textarea):
"fullscreen": _("Toggle fullscreen"),
"guide": _("Markdown guide"),
}
context["markdown_api_url"] = reverse("api:api_markdown")
context["markdown_api_url"] = reverse("api:markdown")
return context

View File

@ -29,6 +29,7 @@ from smtplib import SMTPException
from django.conf import settings
from django.contrib.auth import login, views
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory
@ -50,7 +51,6 @@ from django.views.generic.dates import MonthMixin, YearMixin
from django.views.generic.edit import FormView, UpdateView
from honeypot.decorators import check_honeypot
from api.views.sas import all_pictures_of_user
from core.models import Gift, Preferences, SithFile, User
from core.views import (
CanEditMixin,
@ -58,7 +58,6 @@ from core.views import (
CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
UserIsLoggedMixin,
)
from core.views.forms import (
GiftForm,
@ -68,6 +67,7 @@ from core.views.forms import (
UserProfileForm,
)
from counter.forms import StudentCardForm
from sas.models import Picture
from subscription.models import Subscription
from trombi.views import UserTrombiForm
@ -313,7 +313,11 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
kwargs = super().get_context_data(**kwargs)
kwargs["albums"] = []
kwargs["pictures"] = {}
picture_qs = all_pictures_of_user(self.object)
picture_qs = (
Picture.objects.filter(people__user_id=self.object.id)
.order_by("parent__date", "id")
.all()
)
last_album = None
for picture in picture_qs:
album = picture.parent
@ -720,7 +724,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "groups"
class UserToolsView(QuickNotifMixin, UserTabsMixin, UserIsLoggedMixin, TemplateView):
class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, TemplateView):
"""Displays the logged user's tools."""
template_name = "core/user_tools.jinja"