replace drf by django-ninja

This commit is contained in:
thomas girod
2024-07-18 20:23:30 +02:00
parent d9531838f2
commit 3046438cb1
43 changed files with 1001 additions and 1191 deletions

36
pedagogy/api.py Normal file
View File

@ -0,0 +1,36 @@
from typing import Annotated
from annotated_types import Ge
from django.conf import settings
from ninja import Query
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound
from core.api_permissions import IsInGroup, IsRoot, IsSubscriber
from pedagogy.models import UV
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
from pedagogy.utbm_api import find_uv
@api_controller("/uv", permissions=[IsSubscriber])
class UvController(ControllerBase):
@route.get(
"/{year}/{code}",
permissions=[IsRoot | IsInGroup(settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)],
url_name="fetch_uv_from_utbm",
response=UvSchema,
)
def fetch_from_utbm_api(
self, year: Annotated[int, Ge(2010)], code: str, lang: Query[str] = "fr"
):
"""Fetch UV data from the UTBM API and returns it after some parsing."""
res = find_uv(lang, year, code)
if res is None:
raise NotFound
return res
@route.get("", response=list[SimpleUvSchema], url_name="fetch_uvs")
def fetch_uv_list(self, search: Query[UvFilterSchema]):
# le `[:50]`, c'est de la pagination eco+
# si quelqu'un est motivé, il peut faire une vraie pagination
return search.filter(UV.objects.all())[:50]

View File

@ -28,7 +28,6 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from core.models import User
@ -327,30 +326,3 @@ class UVCommentReport(models.Model):
def is_owned_by(self, user):
"""Can be created by a pedagogy admin, a superuser or a subscriber."""
return user.is_subscribed or user.is_owner(self.comment.uv)
# Custom serializers
class UVSerializer(serializers.ModelSerializer):
"""Custom seralizer for UVs.
Allow adding more informations like absolute_url.
"""
class Meta:
model = UV
fields = "__all__"
absolute_url = serializers.SerializerMethodField()
update_url = serializers.SerializerMethodField()
delete_url = serializers.SerializerMethodField()
def get_absolute_url(self, obj):
return obj.get_absolute_url()
def get_update_url(self, obj):
return reverse("pedagogy:uv_update", kwargs={"uv_id": obj.id})
def get_delete_url(self, obj):
return reverse("pedagogy:uv_delete", kwargs={"uv_id": obj.id})

132
pedagogy/schemas.py Normal file
View File

@ -0,0 +1,132 @@
from typing import Literal
from django.db.models import Q
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasPath, ConfigDict, Field, TypeAdapter
from pydantic.alias_generators import to_camel
from pedagogy.models import UV
class UtbmShortUvSchema(Schema):
"""Short representation of an UV in the UTBM API.
Notes:
This schema holds only the fields we actually need.
The UTBM API returns more data than that.
"""
model_config = ConfigDict(alias_generator=to_camel)
code: str
code_formation: str
code_categorie: str | None
code_langue: str
ouvert_automne: bool
ouvert_printemps: bool
class WorkloadSchema(Schema):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
code: Literal["TD", "TP", "CM", "THE", "TE"]
nbh: int
class SemesterUvState(Schema):
"""The state of the UV during either autumn or spring semester"""
model_config = ConfigDict(alias_generator=to_camel)
responsable: str
ouvert: bool
ShortUvList = TypeAdapter(list[UtbmShortUvSchema])
class UtbmFullUvSchema(Schema):
"""Long representation of an UV in the UTBM API."""
model_config = ConfigDict(alias_generator=to_camel)
code: str
departement: str = "NA"
libelle: str
objectifs: str
programme: str
acquisition_competences: str
acquisition_notions: str
langue: str
code_langue: str
credits_ects: int
activites: list[WorkloadSchema]
respo_automne: str | None = Field(
None, validation_alias=AliasPath("automne", "responsable")
)
respo_printemps: str | None = Field(
None, validation_alias=AliasPath("printemps", "responsable")
)
class SimpleUvSchema(ModelSchema):
"""Our minimal representation of an UV."""
class Meta:
model = UV
fields = [
"id",
"title",
"code",
"credit_type",
"semester",
"department",
]
class UvSchema(ModelSchema):
"""Our complete representation of an UV"""
class Meta:
model = UV
fields = [
"id",
"title",
"code",
"hours_THE",
"hours_TD",
"hours_TP",
"hours_TE",
"hours_CM",
"credit_type",
"semester",
"language",
"department",
"credits",
"manager",
"skills",
"key_concepts",
"objectives",
"program",
]
class UvFilterSchema(FilterSchema):
search: str | None = Field(None, q="code__icontains")
semester: set[Literal["AUTUMN", "SPRING"]] | None = None
credit_type: set[Literal["CS", "TM", "EC", "OM", "QC"]] | None = Field(
None, q="credit_type__in"
)
language: str = "FR"
department: set[str] | None = Field(None, q="department__in")
def filter_semester(self, value: set[str] | None) -> Q:
"""Special filter for the semester.
If both "SPRING" and "AUTUMN" are given, UV that are available
during "AUTUMN_AND_SPRING" will be filtered.
"""
if not value:
return Q()
value.add("AUTUMN_AND_SPRING")
return Q(semester__in=value)

View File

@ -5,52 +5,79 @@
{% trans %}UV Guide{% endtrans %}
{% endblock %}
{% block additional_js %}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block head %}
{{ super() }}
<meta name="viewport" content="width=device-width, initial-scale=0.6, maximum-scale=2">
{% endblock head %}
{% block content %}
<div class="pedagogy">
<form id="search_form" action="{{ url('pedagogy:guide') }}" method="get">
{% if can_create_uv %}
<div class="action-bar">
<p>
<a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a>
</p>
<p>
<a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a>
</p>
</div>
<br/>
{% endif %}
<div class="pedagogy" x-data="uv_search" x-cloak>
<form id="search_form">
<div class="search-form-container">
{% if can_create_uv(user) %}
<div class="action-bar">
<p>
<a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a>
</p>
<p>
<a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a>
</p>
</div>
{% endif %}
<div class="search-bar">
<input id="search_input" class="search-bar-input" type="text" name="search">
<button class="search-bar-button">{% trans %}Search{% endtrans %}</button>
<input
id="search_input"
class="search-bar-input"
type="text"
name="search"
x-model.debounce.500ms="search"
/>
</div>
<div class="radio-department">
<div class="radio-guide">
{% for (display_name, real_name) in [("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"), ("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC")] %}
<input type="radio" name="department" id="radio{{ real_name }}" value="{{ real_name }}"><label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label>
{% for (display_name, real_name) in [
("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"),
("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC")
] %}
<input
type="checkbox"
name="department"
id="radio{{ real_name }}"
value="{{ real_name }}"
x-model="department"
/>
<label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label>
{% endfor %}
</div>
</div>
<div class="radio-credit-type">
<div class="radio-guide">
{% for credit_type in ["CS", "TM", "EC", "QC", "OM"] %}
<input type="radio" name="credit_type" id="radio{{ credit_type }}" value="{{ credit_type }}"><label for="radio{{ credit_type }}">{% trans %}{{ credit_type }}{% endtrans %}</label>
<input
type="checkbox"
name="credit_type"
id="radio{{ credit_type }}"
value="{{ credit_type }}"
x-model="credit_type"
/>
<label for="radio{{ credit_type }}">{% trans %}{{ credit_type }}{% endtrans %}</label>
{% endfor %}
</div>
</div>
<div class="radio-semester">
<div class="radio-guide">
<input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN"><label for="radioAUTUMN"><i class="fa fa-leaf"></i></label>
<input type="checkbox" name="semester" id="radioSPRING" value="SPRING"><label for="radioSPRING"><i class="fa fa-sun-o"></i></label>
<span><input type="checkbox" name="semester" id="radioAP" value="AUTUMN_AND_SPRING"><label for="radioAP">AP</label></span>
<input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN" x-model="semester"/>
<label for="radioAUTUMN"><i class="fa fa-sun-o"></i></label>
<input type="checkbox" name="semester" id="radioSPRING" value="SPRING" x-model="semester"/>
<label for="radioSPRING"><i class="fa fa-leaf"></i></label>
</div>
</div>
<input type="text" name="json" hidden>
</div>
</form>
<table id="dynamic_view">
@ -62,185 +89,84 @@
<td>{% trans %}Credit type{% endtrans %}</td>
<td><i class="fa fa-leaf"></i></td>
<td><i class="fa fa-sun-o"></i></td>
{% if can_create_uv(user) %}
<td>{% trans %}Edit{% endtrans %}</td>
<td>{% trans %}Delete{% endtrans %}</td>
{% if can_create_uv %}
<td>{% trans %}Edit{% endtrans %}</td>
<td>{% trans %}Delete{% endtrans %}</td>
{% endif %}
</tr>
</thead>
<tbody id="dynamic_view_content">
{% for uv in object_list %}
<tr onclick="window.location.href = `{{ url('pedagogy:uv_detail', uv_id=uv.id) }}`">
<td><a href="{{ url('pedagogy:uv_detail', uv_id=uv.id) }}">{{ uv.code }}</a></td>
<td>{{ uv.title }}</td>
<td>{{ uv.department }}</td>
<td>{{ uv.credit_type }}</td>
<td>
{% if uv.semester in ["AUTUMN", "AUTUMN_AND_SPRING"] %}
<i class="fa fa-leaf"></i>
{% endif %}
</td>
<td>
{% if uv.semester in ["SPRING", "AUTUMN_AND_SPRING"] %}
<i class="fa fa-sun-o"></i>
{% endif %}
</td>
{% if user.is_owner(uv) -%}
<td><a href="{{ url('pedagogy:uv_update', uv_id=uv.id) }}">{% trans %}Edit{% endtrans %}</a></td>
<td><a href="{{ url('pedagogy:uv_delete', uv_id=uv.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{%- endif -%}
</tr>
{% endfor %}
<template x-for="uv in uvs" :key="uv.id">
<tr @click="window.location.href = `/pedagogy/uv/${uv.id}`">
<td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
<td x-text="uv.title"></td>
<td x-text="uv.department"></td>
<td x-text="uv.credit_type"></td>
<td><i :class="[uv.semester].includes('AUTUMN') && 'fa fa-leaf'"></i></td>
<td><i :class="[uv.semester].includes('SPRING') && 'fa fa-sun-o'"></i></td>
{% if can_create_uv -%}
<td><a :href="`/pedagogy/uv/${uv.id}/update`">{% trans %}Edit{% endtrans %}</a></td>
<td><a :href="`/pedagogy/uv/${uv.id}/delete`">{% trans %}Delete{% endtrans %}</a></td>
{%- endif -%}
</tr>
</template>
</tbody>
</table>
</div>
<script>
function autofillCheckboxRadio(name){
if (urlParams.has(name)){ $("input[name='" + name + "']").each(function(){
if ($(this).attr("value") == urlParams.get(name))
$(this).prop("checked", true);
});
}
}
const initialUrlParams = new URLSearchParams(window.location.search);
function uvJSONToHTML(uv){
var autumn = "";
var spring = "";
if (uv.semester == "AUTUMN" || uv.semester == "AUTUMN_AND_SPRING")
autumn = "<i class='fa fa-leaf'></i>";
if (uv.semester == "SPRING" || uv.semester == "AUTUMN_AND_SPRING")
spring = "<i class='fa fa-sun-o'></i>";
var html = `
<tr onclick="window.location.href = '${uv.absolute_url}';">
<td><a href="${uv.absolute_url}">${uv.code}</a></td>
<td>${uv.title}</td>
<td>${uv.department}</td>
<td>${uv.credit_type}</td>
<td>${autumn}</td>
<td>${spring}</td>
`;
{% if can_create_uv(user) %}
html += `
<td><a href="${uv.update_url}">{% trans %}Edit{% endtrans %}</a></td>
<td><a href="${uv.delete_url}">{% trans %}Delete{% endtrans %}</a></td>
`;
{% endif %}
return html + "</td>";
}
var lastTypedLetter;
$("#search_input").on("keyup", function(){
// Auto submit when user pauses it's typing
clearTimeout(lastTypedLetter);
lastTypedLetter = setTimeout(function (){
$("#search_form").submit();
}, 300);
});
$("#search_input").on("change", function(e){
// Don't send request when leaving the text area
// It has already been send by the keypress event
e.preventDefault();
});
// Auto fill from get arguments
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("search"))
$("input[name='search']").first().prop("value", urlParams.get("search"));
autofillCheckboxRadio("department");
autofillCheckboxRadio("credit_type");
autofillCheckboxRadio("semester");
// Allow unchecking a radio button when we click on it
// Keep a state of what is checked
var formStates = {};
function radioCheckToggle(e){
if (formStates[this.name] == this.value){
this.checked = false;
formStates[this.name] = "";
// Fire an update since the browser does not do it in this situation
$("#search_form").submit();
return;
}
formStates[this.name] = this.value;
}
$("input[type='radio']").each(function() {
$(this).on("click", radioCheckToggle);
// Get current state
if ($(this).prop("checked")){
formStates[$(this).attr("name")] = $(this).attr("value");
}
});
var autumn_and_spring = $("input[value='AUTUMN_AND_SPRING']").first();
var autumn = $("input[value='AUTUMN']").first();
var spring = $("input[value='SPRING']").first();
// Make autumn and spring hidden if js is enabled
autumn_and_spring.parent().hide();
// Fill json field if js is enabled
$("input[name='json']").first().prop("value", "true");
// Set correctly state of what is checked
if (autumn_and_spring.prop("checked")){
autumn.prop("checked", true);
spring.prop("checked", true);
autumn_and_spring.prop("checked", false);
function update_query_string(key, value) {
const url = new URL(window.location.href);
console.log(value)
console.log(!!value)
if (!value) {
url.searchParams.delete(key)
} else if (Array.isArray(value)) {
url.searchParams.delete(key)
value.forEach((v) => url.searchParams.append(key, v))
} else {
url.searchParams.set(key, value);
}
history.pushState(null, document.title, url.toString());
}
// Handle submit here and modify autumn and spring here
$("#search_form").submit(function(e) {
e.preventDefault();
if (autumn.prop("checked") && spring.prop("checked")){
autumn_and_spring.prop("checked", true);
autumn.prop("checked", false);
spring.prop("checked", false);
}
{#
How does this work :
// Do query
var xhr = new XMLHttpRequest();
$.ajax({
type: "GET",
url: "{{ url('pedagogy:guide') }}",
data: $(this).serialize(),
tryCount: 0,
retryLimit: 10,
xhr: function(){
return xhr;
},
success: function(data){
// Update URL
history.pushState({}, null, xhr.responseURL.replace("&json=true", ""));
// Update content
$("#dynamic_view_content").html("");
for (key in data){
$("#dynamic_view_content").append(uvJSONToHTML(data[key]));
}
},
error: function(){
console.log(`try ${this.tryCount}`);
if (this.tryCount++ <= this.retryLimit){
$("dynamic_view_content").html("");
$.ajax(this);
return;
}
$("#dynamic_view_content").html("<tr><td></td><td>{% trans %}Error connecting to the server{% endtrans %}</td></tr>");
}
});
The page contains two main elements : the form and the results.
The form contains multiple inputs, allowing the user to apply the filter of its choice.
Each modification of those filters will modify the GET parameters of the URL,
then fetch the corresponding data from the API.
This data will then be displayed on the result part of the page.
#}
document.addEventListener("alpine:init", () => {
Alpine.data("uv_search", () => ({
uvs: [],
search: initialUrlParams.get("search") || "",
department: initialUrlParams.getAll("department"),
credit_type: initialUrlParams.getAll("credit_type"),
{# The semester is easier to use on the backend as an enum (spring/autumn/both/none)
and easier to use on the frontend as an array ([spring, autumn]).
Thus there is some conversion involved when both communicate together #}
semester: initialUrlParams.has("semester") ?
initialUrlParams.get("semester").split("_AND_") : [],
// Restore autumn and spring for perfect illusion
if (autumn_and_spring.prop("checked")){
autumn_and_spring.prop("checked", false);
autumn.prop("checked", true);
spring.prop("checked", true);
}
});
async init() {
["search", "department", "credit_type", "semester"].forEach((param) => {
this.$watch(param, async (value) => {
update_query_string(param, value);
await this.fetch_data(); {# reload data on form change #}
});
})
await this.fetch_data(); {# load initial data #}
},
// Auto send on change
$("#search_form").on("change", function(e){
$(this).submit();
});
async fetch_data() {
const url = "{{ url("api:fetch_uvs") }}" + window.location.search;
this.uvs = await (await fetch(url)).json();
}
}))
})
</script>
{% endblock content %}

View File

@ -51,29 +51,31 @@
if (today.getMonth() < 7) { // student year starts in september
year--
}
const url = "{{ url('api:uv_endpoint') }}?year=" + year + "&code=" + codeInput.value
const url = `/api/uv/${year}/${codeInput.value}`;
deleteQuickNotifs()
$.ajax({
dataType: "json",
url: url,
success: function(data, _, xhr) {
if (xhr.status != 200) {
if (xhr.status !== 200) {
createQuickNotif("{% trans %}Unknown UE code{% endtrans %}")
return
}
for (let key in data) {
if (data.hasOwnProperty(key)) {
const el = document.querySelector('[name="' + key + '"]')
if (el.tagName == 'TEXTAREA') {
el.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(data[key])
Object.entries(data)
.filter(([_, val]) => !!val) // skip entries with null or undefined value
.map(([key, val]) => { // convert keys to DOM elements
return [document.querySelector('[name="' + key + '"]'), val];
})
.filter(([elem, _]) => !!elem) // skip non-existing DOM elements
.forEach(([elem, val]) => { // write the value in the form field
if (elem.tagName === 'TEXTAREA') {
// MD editor text input
elem.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(val);
} else {
el.value = data[key]
elem.value = val;
}
}
}
});
createQuickNotif('{% trans %}Successful autocomplete{% endtrans %}')
},
error: function(_, _, statusMessage) {

View File

@ -20,10 +20,11 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import json
import pytest
from django.conf import settings
from django.core.management import call_command
from django.test import TestCase
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -141,41 +142,27 @@ class UVCreation(TestCase):
assert not UV.objects.filter(code="IFC1").exists()
class UVListTest(TestCase):
"""Test guide display rights."""
@pytest.mark.django_db
@pytest.mark.parametrize(
("username", "expected_code"),
[
("root", 200),
("tutu", 200),
("sli", 200),
("old_subscriber", 200),
("public", 403),
],
)
def test_guide_permissions(client: Client, username: str, expected_code: int):
client.force_login(User.objects.get(username=username))
res = client.get(reverse("pedagogy:guide"))
assert res.status_code == expected_code
@classmethod
def setUpTestData(cls):
cls.bibou = User.objects.get(username="root")
cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy")
def test_uv_list_display_success(self):
# Display for root
self.client.force_login(self.bibou)
response = self.client.get(reverse("pedagogy:guide"))
self.assertContains(response, text="PA00")
# Display for pedagogy admin
self.client.force_login(self.tutu)
response = self.client.get(reverse("pedagogy:guide"))
self.assertContains(response, text="PA00")
# Display for simple subscriber
self.client.force_login(self.sli)
response = self.client.get(reverse("pedagogy:guide"))
self.assertContains(response, text="PA00")
def test_uv_list_display_fail(self):
# Don't display for anonymous user
response = self.client.get(reverse("pedagogy:guide"))
assert response.status_code == 403
# Don't display for none subscribed users
self.client.force_login(self.guy)
response = self.client.get(reverse("pedagogy:guide"))
assert response.status_code == 403
@pytest.mark.django_db
def test_guide_anonymous_permission_denied(client: Client):
res = client.get(reverse("pedagogy:guide"))
assert res.status_code == 302
class UVDeleteTest(TestCase):
@ -577,141 +564,111 @@ class UVSearchTest(TestCase):
cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy")
cls.url = reverse("api:fetch_uvs")
uvs = [
UV(code="AP4A", credit_type="CS", semester="AUTUMN", department="GI"),
UV(code="MT01", credit_type="CS", semester="AUTUMN", department="TC"),
UV(code="PHYS11", credit_type="CS", semester="AUTUMN", department="TC"),
UV(code="TNEV", credit_type="TM", semester="SPRING", department="TC"),
UV(code="MT10", credit_type="TM", semester="AUTUMN", department="IMSI"),
UV(
code="DA50",
credit_type="TM",
semester="AUTUMN_AND_SPRING",
department="GI",
),
]
for uv in uvs:
uv.author = cls.bibou
uv.title = ""
uv.manager = ""
uv.language = "FR"
uv.objectives = ""
uv.program = ""
uv.skills = ""
uv.key_concepts = ""
uv.credits = 6
UV.objects.bulk_create(uvs)
def setUp(self):
call_command("update_index", "pedagogy")
def fetch_uvs(self, **kwargs):
params = "&".join(f"{key}={val}" for key, val in kwargs.items())
return json.loads(f"{self.url}?{params}")
def test_get_page_authorized_success(self):
# Test with root user
self.client.force_login(self.bibou)
response = self.client.get(reverse("pedagogy:guide"))
assert response.status_code == 200
# Test with pedagogy admin
self.client.force_login(self.tutu)
response = self.client.get(reverse("pedagogy:guide"))
assert response.status_code == 200
# Test with subscribed user
self.client.force_login(self.sli)
response = self.client.get(reverse("pedagogy:guide"))
assert response.status_code == 200
def test_get_page_unauthorized_fail(self):
def test_permissions(self):
# Test with anonymous user
response = self.client.get(reverse("pedagogy:guide"))
response = self.client.get(self.url)
assert response.status_code == 403
# Test with not subscribed user
self.client.force_login(self.guy)
response = self.client.get(reverse("pedagogy:guide"))
response = self.client.get(self.url)
assert response.status_code == 403
def test_search_pa00_success(self):
self.client.force_login(self.sli)
for user in self.bibou, self.tutu, self.sli:
# users that have right
with self.subTest():
self.client.force_login(user)
response = self.client.get(self.url)
assert response.status_code == 200
# Search with UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "PA00"})
self.assertContains(response, text="PA00")
# Search with first letter of UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "P"})
self.assertContains(response, text="PA00")
# Search with first letter of UV code in lowercase
response = self.client.get(reverse("pedagogy:guide"), {"search": "p"})
self.assertContains(response, text="PA00")
# Search with UV title
response = self.client.get(
reverse("pedagogy:guide"), {"search": "participation"}
)
self.assertContains(response, text="PA00")
# Search with UV manager
response = self.client.get(reverse("pedagogy:guide"), {"search": "HEYBERGER"})
self.assertContains(response, text="PA00")
# Search with department
response = self.client.get(reverse("pedagogy:guide"), {"department": "HUMA"})
self.assertContains(response, text="PA00")
# Search with semester
response = self.client.get(reverse("pedagogy:guide"), {"semester": "AUTUMN"})
self.assertContains(response, text="PA00")
response = self.client.get(reverse("pedagogy:guide"), {"semester": "SPRING"})
self.assertContains(response, text="PA00")
response = self.client.get(
reverse("pedagogy:guide"), {"semester": "AUTUMN_AND_SPRING"}
)
self.assertContains(response, text="PA00")
# Search with language
response = self.client.get(reverse("pedagogy:guide"), {"language": "FR"})
self.assertContains(response, text="PA00")
# Search with credit type
response = self.client.get(reverse("pedagogy:guide"), {"credit_type": "OM"})
self.assertContains(response, text="PA00")
# Search with combinaison of all
response = self.client.get(
reverse("pedagogy:guide"),
def test_format(self):
"""Test that the return data format is correct"""
self.client.force_login(self.bibou)
res = self.client.get(self.url + "?search=PA00")
uv = UV.objects.get(code="PA00")
assert res.status_code == 200
assert json.loads(res.content) == [
{
"search": "P",
"department": "HUMA",
"semester": "AUTUMN",
"language": "FR",
"credit_type": "OM",
},
)
self.assertContains(response, text="PA00")
"id": uv.id,
"title": uv.title,
"code": uv.code,
"credit_type": uv.credit_type,
"semester": uv.semester,
"department": uv.department,
}
]
# Test json briefly
response = self.client.get(
reverse("pedagogy:guide"),
{
"json": "t",
"search": "P",
"department": "HUMA",
"semester": "AUTUMN",
"language": "FR",
"credit_type": "OM",
},
)
self.assertJSONEqual(
response.content,
[
{
"id": 1,
"absolute_url": "/pedagogy/uv/1/",
"update_url": "/pedagogy/uv/1/edit/",
"delete_url": "/pedagogy/uv/1/delete/",
"code": "PA00",
"author": 0,
"credit_type": "OM",
"semester": "AUTUMN_AND_SPRING",
"language": "FR",
"credits": 5,
"department": "HUMA",
"title": "Participation dans une association \u00e9tudiante",
"manager": "Laurent HEYBERGER",
"objectives": "* Permettre aux \u00e9tudiants de r\u00e9aliser, pendant un semestre, un projet culturel ou associatif et de le valoriser.",
"program": "* Semestre pr\u00e9c\u00e9dent proposition d'un projet et d'un cahier des charges\n* Evaluation par un jury de six membres\n* Si accord r\u00e9alisation dans le cadre de l'UV\n* Compte-rendu de l'exp\u00e9rience\n* Pr\u00e9sentation",
"skills": "* G\u00e9rer un projet associatif ou une action \u00e9ducative en autonomie:\n* en produisant un cahier des charges qui -d\u00e9finit clairement le contexte du projet personnel -pose les jalons de ce projet -estime de mani\u00e8re r\u00e9aliste les moyens et objectifs du projet -d\u00e9finit exactement les livrables attendus\n * en \u00e9tant capable de respecter ce cahier des charges ou, le cas \u00e9ch\u00e9ant, de r\u00e9viser le cahier des charges de mani\u00e8re argument\u00e9e.\n* Relater son exp\u00e9rience dans un rapport:\n* qui permettra \u00e0 d'autres \u00e9tudiants de poursuivre les actions engag\u00e9es\n* qui montre la capacit\u00e9 \u00e0 s'auto-\u00e9valuer et \u00e0 adopter une distance critique sur son action.",
"key_concepts": "* Autonomie\n* Responsabilit\u00e9\n* Cahier des charges\n* Gestion de projet",
"hours_CM": 0,
"hours_TD": 0,
"hours_TP": 0,
"hours_THE": 121,
"hours_TE": 4,
}
],
def test_search_by_code(self):
self.client.force_login(self.bibou)
res = self.client.get(self.url + "?search=MT")
assert res.status_code == 200
assert {uv["code"] for uv in json.loads(res.content)} == {"MT01", "MT10"}
def test_search_by_credit_type(self):
self.client.force_login(self.bibou)
res = self.client.get(self.url + "?credit_type=CS")
assert res.status_code == 200
codes = [uv["code"] for uv in json.loads(res.content)]
assert codes == ["AP4A", "MT01", "PHYS11"]
res = self.client.get(self.url + "?credit_type=CS&credit_type=OM")
assert res.status_code == 200
codes = {uv["code"] for uv in json.loads(res.content)}
assert codes == {"AP4A", "MT01", "PHYS11", "PA00"}
def test_search_by_semester(self):
self.client.force_login(self.bibou)
res = self.client.get(self.url + "?semester=SPRING")
assert res.status_code == 200
codes = {uv["code"] for uv in json.loads(res.content)}
assert codes == {"DA50", "TNEV", "PA00"}
def test_search_multiple_filters(self):
self.client.force_login(self.bibou)
res = self.client.get(
self.url + "?semester=AUTUMN&credit_type=CS&department=TC"
)
assert res.status_code == 200
codes = {uv["code"] for uv in json.loads(res.content)}
assert codes == {"MT01", "PHYS11"}
def test_search_fails(self):
self.client.force_login(self.bibou)
res = self.client.get(self.url + "?credit_type=CS&search=DA")
assert res.status_code == 200
assert json.loads(res.content) == []
def test_search_pa00_fail(self):
self.client.force_login(self.bibou)
# Search with UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"})
self.assertNotContains(response, text="PA00")

View File

@ -27,7 +27,7 @@ from pedagogy.views import *
urlpatterns = [
# Urls displaying the actual application for visitors
path("", UVListView.as_view(), name="guide"),
path("", UVGuideView.as_view(), name="guide"),
path("uv/<int:uv_id>/", UVDetailFormView.as_view(), name="uv_detail"),
path(
"comment/<int:comment_id>/edit/",

81
pedagogy/utbm_api.py Normal file
View File

@ -0,0 +1,81 @@
"""Set of functions to interact with the UTBM UV api."""
import urllib
from django.conf import settings
from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema
def find_uv(lang, year, code) -> UvSchema | None:
"""Find an UV from the UTBM API."""
# query the UV list
base_url = settings.SITH_PEDAGOGY_UTBM_API
uvs_url = f"{base_url}/uvs/{lang}/{year}"
response = urllib.request.urlopen(uvs_url)
uvs: list[UtbmShortUvSchema] = ShortUvList.validate_json(response.read())
short_uv = next((uv for uv in uvs if uv.code == code), None)
if short_uv is None:
return None
# get detailed information about the UV
uv_url = f"{base_url}/uv/{lang}/{year}/{code}/{short_uv.code_formation}"
response = urllib.request.urlopen(uv_url)
full_uv = UtbmFullUvSchema.model_validate_json(response.read())
return _make_clean_uv(short_uv, full_uv)
def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
"""Cleans the data up so that it corresponds to our data representation.
Some of the needed information are in the short uv schema, some
other in the full uv schema.
Thus we combine those information to obtain a data schema suitable
for our needs.
"""
if full_uv.departement == "Pôle Humanités":
department = "HUMA"
else:
department = {
"AL": "IMSI",
"AE": "EE",
"GI": "GI",
"GC": "EE",
"GM": "MC",
"TC": "TC",
"GP": "IMSI",
"ED": "EDIM",
"AI": "GI",
"AM": "MC",
}.get(short_uv.code_formation, "NA")
match short_uv.ouvert_printemps, short_uv.ouvert_automne:
case True, True:
semester = "AUTUMN_AND_SPRING"
case True, False:
semester = "SPRING"
case False, True:
semester = "AUTUMN"
case _:
semester = "CLOSED"
return UvSchema(
title=full_uv.libelle,
code=full_uv.code,
credit_type=short_uv.code_categorie,
semester=semester,
language=short_uv.code_langue.upper(),
credits=full_uv.credits_ects,
department=department,
hours_THE=next((i.nbh for i in full_uv.activites if i.code == "THE"), 0) // 60,
hours_TD=next((i.nbh for i in full_uv.activites if i.code == "TD"), 0) // 60,
hours_TP=next((i.nbh for i in full_uv.activites if i.code == "TP"), 0) // 60,
hours_TE=next((i.nbh for i in full_uv.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_uv.activites if i.code == "CM"), 0) // 60,
manager=full_uv.respo_automne or full_uv.respo_printemps or "",
objectives=full_uv.objectifs,
program=full_uv.programme,
skills=full_uv.acquisition_competences,
key_concepts=full_uv.acquisition_notions,
)

View File

@ -22,21 +22,17 @@
#
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.utils import html
from django.views.generic import (
CreateView,
DeleteView,
FormView,
ListView,
TemplateView,
UpdateView,
View,
)
from haystack.query import SearchQuerySet
from rest_framework.renderers import JSONRenderer
from core.models import Notification, RealGroup
from core.views import (
@ -44,6 +40,7 @@ from core.views import (
CanEditPropMixin,
CanViewMixin,
DetailFormView,
FormerSubscriberMixin,
)
from pedagogy.forms import (
UVCommentForm,
@ -51,30 +48,12 @@ from pedagogy.forms import (
UVCommentReportForm,
UVForm,
)
from pedagogy.models import UV, UVComment, UVCommentReport, UVSerializer
# Some mixins
class CanCreateUVFunctionMixin(View):
"""Add the function can_create_uv(user) into the template."""
@staticmethod
def can_create_uv(user):
"""Creates a dummy instance of UV and test is_owner."""
return user.is_owner(UV())
def get_context_data(self, **kwargs):
"""Pass the function to the template."""
kwargs = super().get_context_data(**kwargs)
kwargs["can_create_uv"] = self.can_create_uv
return kwargs
from pedagogy.models import UV, UVComment, UVCommentReport
# Acutal views
class UVDetailFormView(CanViewMixin, CanCreateUVFunctionMixin, DetailFormView):
class UVDetailFormView(CanViewMixin, DetailFormView):
"""Display every comment of an UV and detailed infos about it.
Allow to comment the UV.
@ -101,6 +80,15 @@ class UVDetailFormView(CanViewMixin, CanCreateUVFunctionMixin, DetailFormView):
"pedagogy:uv_detail", kwargs={"uv_id": self.get_object().id}
)
def get_context_data(self, **kwargs):
user = self.request.user
return super().get_context_data(**kwargs) | {
"can_create_uv": (
user.is_root
or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
)
}
class UVCommentUpdateView(CanEditPropMixin, UpdateView):
"""Allow edit of a given comment."""
@ -134,65 +122,19 @@ class UVCommentDeleteView(CanEditPropMixin, DeleteView):
return reverse_lazy("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv.id})
class UVListView(CanViewMixin, CanCreateUVFunctionMixin, ListView):
class UVGuideView(LoginRequiredMixin, FormerSubscriberMixin, TemplateView):
"""UV guide main page."""
# This is very basic and is prone to changment
model = UV
ordering = ["code"]
template_name = "pedagogy/guide.jinja"
def get(self, *args, **kwargs):
if not self.request.GET.get("json", None):
# Return normal full template response
return super().get(*args, **kwargs)
# Return serialized response
return HttpResponse(
JSONRenderer().render(UVSerializer(self.get_queryset(), many=True).data),
content_type="application/json",
)
def get_queryset(self):
queryset = super().get_queryset()
search = self.request.GET.get("search", None)
additional_filters = {}
for filter_type in ["credit_type", "language", "department"]:
arg = self.request.GET.get(filter_type, None)
if arg:
additional_filters[filter_type] = arg
semester = self.request.GET.get("semester", None)
if semester:
if semester in ["AUTUMN", "SPRING"]:
additional_filters["semester__in"] = [semester, "AUTUMN_AND_SPRING"]
else:
additional_filters["semester"] = semester
queryset = queryset.filter(**additional_filters)
if not search:
return queryset
if len(search) == 1:
# It's a search with only one letter
# Haystack doesn't work well with only one letter
return queryset.filter(code__istartswith=search)
try:
qs = (
SearchQuerySet()
.models(self.model)
.autocomplete(auto=html.escape(search))
def get_context_data(self, **kwargs):
user = self.request.user
return super().get_context_data(**kwargs) | {
"can_create_uv": (
user.is_root
or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
)
except TypeError:
return self.model.objects.none()
return queryset.filter(
id__in=([o.object.id for o in qs if o.object is not None])
)
}
class UVCommentReportCreateView(CanCreateMixin, CreateView):