Merge pull request #1010 from ae-utbm/populate-all-uvs

Management command to populate all uvs
This commit is contained in:
thomas girod 2025-02-15 18:28:42 +01:00 committed by GitHub
commit b31445fefb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 182 additions and 71 deletions

View File

@ -2,7 +2,7 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import final from typing import final
import urllib3 import requests
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
@ -35,16 +35,15 @@ class IcsCalendar:
@classmethod @classmethod
def make_external(cls) -> Path | None: def make_external(cls) -> Path | None:
calendar = urllib3.request( calendar = requests.get(
"GET", "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics"
"https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics",
) )
if calendar.status != 200: if not calendar.ok:
return None return None
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._EXTERNAL_CALENDAR, "wb") as f: with open(cls._EXTERNAL_CALENDAR, "wb") as f:
_ = f.write(calendar.data) _ = f.write(calendar.content)
return cls._EXTERNAL_CALENDAR return cls._EXTERNAL_CALENDAR
@classmethod @classmethod

View File

@ -16,11 +16,11 @@ from com.calendar import IcsCalendar
@dataclass @dataclass
class MockResponse: class MockResponse:
status: int ok: bool
value: str value: str
@property @property
def data(self): def content(self):
return self.value.encode("utf8") return self.value.encode("utf8")
@ -38,7 +38,7 @@ class TestExternalCalendar:
@pytest.fixture @pytest.fixture
def mock_request(self): def mock_request(self):
mock = MagicMock() mock = MagicMock()
with patch("urllib3.request", mock): with patch("requests.get", mock):
yield mock yield mock
@pytest.fixture @pytest.fixture
@ -52,15 +52,12 @@ class TestExternalCalendar:
def clear_cache(self): def clear_cache(self):
IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True) IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True)
@pytest.mark.parametrize("error_code", [403, 404, 500]) def test_fetch_error(self, client: Client, mock_request: MagicMock):
def test_fetch_error( mock_request.return_value = MockResponse(ok=False, value="not allowed")
self, client: Client, mock_request: MagicMock, error_code: int
):
mock_request.return_value = MockResponse(error_code, "not allowed")
assert client.get(reverse("api:calendar_external")).status_code == 404 assert client.get(reverse("api:calendar_external")).status_code == 404
def test_fetch_success(self, client: Client, mock_request: MagicMock): def test_fetch_success(self, client: Client, mock_request: MagicMock):
external_response = MockResponse(200, "Definitely an ICS") external_response = MockResponse(ok=True, value="Definitely an ICS")
mock_request.return_value = external_response mock_request.return_value = external_response
response = client.get(reverse("api:calendar_external")) response = client.get(reverse("api:calendar_external"))
assert response.status_code == 200 assert response.status_code == 200

View File

@ -935,10 +935,6 @@ msgstr "rôle"
msgid "description" msgid "description"
msgstr "description" msgstr "description"
#: club/models.py
msgid "past member"
msgstr "ancien membre"
#: club/models.py #: club/models.py
msgid "Email address" msgid "Email address"
msgstr "Adresse email" msgstr "Adresse email"
@ -3308,8 +3304,8 @@ msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
#: core/views/forms.py #: core/views/forms.py
msgid "" msgid ""
"Profile: you need to be visible on the picture, in order to be recognized " "Profile: you need to be visible on the picture, in order to be recognized (e."
"(e.g. by the barmen)" "g. by the barmen)"
msgstr "" msgstr ""
"Photo de profil: vous devez être visible sur la photo afin d'être reconnu " "Photo de profil: vous devez être visible sur la photo afin d'être reconnu "
"(par exemple par les barmen)" "(par exemple par les barmen)"
@ -3919,8 +3915,8 @@ msgstr ""
#: counter/templates/counter/mails/account_dump.jinja #: counter/templates/counter/mails/account_dump.jinja
msgid "If you think this was a mistake, please mail us at ae@utbm.fr." msgid "If you think this was a mistake, please mail us at ae@utbm.fr."
msgstr "" msgstr ""
"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à " "Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à ae@utbm."
"ae@utbm.fr." "fr."
#: counter/templates/counter/mails/account_dump.jinja #: counter/templates/counter/mails/account_dump.jinja
msgid "" msgid ""
@ -4948,6 +4944,10 @@ msgstr "Département"
msgid "Credit type" msgid "Credit type"
msgstr "Type de crédit" msgstr "Type de crédit"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "closed uv"
msgstr "uv fermée"
#: pedagogy/templates/pedagogy/macros.jinja #: pedagogy/templates/pedagogy/macros.jinja
msgid " not rated " msgid " not rated "
msgstr "non noté" msgstr "non noté"
@ -5990,3 +5990,6 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#~ msgid "past member"
#~ msgstr "ancien membre"

View File

@ -10,13 +10,13 @@ from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseS
from core.auth.api_permissions import HasPerm from core.auth.api_permissions import HasPerm
from pedagogy.models import UV from pedagogy.models import UV
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
from pedagogy.utbm_api import find_uv from pedagogy.utbm_api import UtbmApiClient
@api_controller("/uv") @api_controller("/uv")
class UvController(ControllerBase): class UvController(ControllerBase):
@route.get( @route.get(
"/{year}/{code}", "/{code}",
permissions=[ permissions=[
# this route will almost always be called in the context # this route will almost always be called in the context
# of a UV creation/edition # of a UV creation/edition
@ -26,10 +26,14 @@ class UvController(ControllerBase):
response=UvSchema, response=UvSchema,
) )
def fetch_from_utbm_api( def fetch_from_utbm_api(
self, year: Annotated[int, Ge(2010)], code: str, lang: Query[str] = "fr" self,
code: str,
lang: Query[str] = "fr",
year: Query[Annotated[int, Ge(2010)] | None] = None,
): ):
"""Fetch UV data from the UTBM API and returns it after some parsing.""" """Fetch UV data from the UTBM API and returns it after some parsing."""
res = find_uv(lang, year, code) with UtbmApiClient() as client:
res = client.find_uv(lang, code, year)
if res is None: if res is None:
raise NotFound raise NotFound
return res return res
@ -42,4 +46,4 @@ class UvController(ControllerBase):
) )
@paginate(PageNumberPaginationExtra, page_size=100) @paginate(PageNumberPaginationExtra, page_size=100)
def fetch_uv_list(self, search: Query[UvFilterSchema]): def fetch_uv_list(self, search: Query[UvFilterSchema]):
return search.filter(UV.objects.values()) return search.filter(UV.objects.order_by("code").values())

View File

View File

View File

@ -0,0 +1,37 @@
from django.conf import settings
from django.core.management import BaseCommand
from core.models import User
from pedagogy.models import UV
from pedagogy.schemas import UvSchema
from pedagogy.utbm_api import UtbmApiClient
class Command(BaseCommand):
help = "Update the UV guide"
def handle(self, *args, **options):
seen_uvs: set[int] = set()
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
with UtbmApiClient() as client:
self.stdout.write(
"Fetching UVs from the UTBM API.\n"
"This may take a few minutes to complete."
)
for uv in client.fetch_uvs():
db_uv = UV.objects.filter(code=uv.code).first()
if db_uv is None:
db_uv = UV(code=uv.code, author=root_user)
fields = list(UvSchema.model_fields.keys())
fields.remove("id")
fields.remove("code")
for field in fields:
setattr(db_uv, field, getattr(uv, field))
db_uv.save()
# if it's a creation, django will set the id when saving,
# so at this point, a db_uv will always have an id
seen_uvs.add(db_uv.id)
# UVs that are in database but have not been returned by the API
# are considered as closed UEs
UV.objects.exclude(id__in=seen_uvs).update(semester="CLOSED")
self.stdout.write(self.style.SUCCESS("UV guide updated successfully"))

View File

@ -54,11 +54,11 @@ class UtbmFullUvSchema(Schema):
code: str code: str
departement: str = "NA" departement: str = "NA"
libelle: str libelle: str | None
objectifs: str objectifs: str | None
programme: str programme: str | None
acquisition_competences: str acquisition_competences: str | None
acquisition_notions: str acquisition_notions: str | None
langue: str langue: str
code_langue: str code_langue: str
credits_ects: int credits_ects: int

View File

@ -47,11 +47,14 @@ $large-devices: 992px;
} }
} }
#dynamic_view { #uv-list {
font-size: 1.1em; font-size: 1.1em;
overflow-wrap: break-word; overflow-wrap: break-word;
.closed td.title {
color: lighten($black-color, 10%);
font-style: italic;
}
td { td {
text-align: center; text-align: center;
border: none; border: none;

View File

@ -85,7 +85,7 @@
</div> </div>
</div> </div>
</form> </form>
<table id="dynamic_view"> <table id="uv-list">
<thead> <thead>
<tr> <tr>
<td>{% trans %}UV{% endtrans %}</td> <td>{% trans %}UV{% endtrans %}</td>
@ -102,11 +102,17 @@
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
<tbody id="dynamic_view_content" :aria-busy="loading"> <tbody :aria-busy="loading">
<template x-for="uv in uvs.results" :key="uv.id"> <template x-for="uv in uvs.results" :key="uv.id">
<tr @click="window.location.href = `/pedagogy/uv/${uv.id}`" class="clickable"> <tr
@click="window.location.href = `/pedagogy/uv/${uv.id}`"
class="clickable"
:class="{closed: uv.semester === 'CLOSED'}"
>
<td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td> <td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
<td x-text="uv.title"></td> <td class="title"
x-text="uv.title + (uv.semester === 'CLOSED' ? ' ({% trans %}closed uv{% endtrans %})' : '')"
></td>
<td x-text="uv.department"></td> <td x-text="uv.department"></td>
<td x-text="uv.credit_type"></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('AUTUMN') && 'fa fa-leaf'"></i></td>

View File

@ -46,12 +46,7 @@
const codeInput = document.querySelector('input[name="code"]') const codeInput = document.querySelector('input[name="code"]')
autofillBtn.addEventListener('click', () => { autofillBtn.addEventListener('click', () => {
const today = new Date() const url = `/api/uv/${codeInput.value}`;
let year = today.getFullYear()
if (today.getMonth() < 7) { // student year starts in september
year--
}
const url = `/api/uv/${year}/${codeInput.value}`;
deleteQuickNotifs() deleteQuickNotifs()
$.ajax({ $.ajax({

View File

@ -1,32 +1,96 @@
"""Set of functions to interact with the UTBM UV api.""" """Set of functions to interact with the UTBM UV api."""
import urllib from typing import Iterator
import requests
from django.conf import settings from django.conf import settings
from django.utils.functional import cached_property
from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema
def find_uv(lang, year, code) -> UvSchema | None: class UtbmApiClient(requests.Session):
"""A wrapper around `requests.Session` to perform requests to the UTBM UV API."""
BASE_URL = settings.SITH_PEDAGOGY_UTBM_API
_cache = {"short_uvs": {}}
@cached_property
def current_year(self) -> int:
"""Fetch from the API the latest existing year"""
url = f"{self.BASE_URL}/guides/fr"
response = self.get(url)
return response.json()[-1]["annee"]
def fetch_short_uvs(
self, lang: str = "fr", year: int | None = None
) -> list[UtbmShortUvSchema]:
"""Get the list of UVs in their short format from the UTBM API"""
if year is None:
year = self.current_year
if lang not in self._cache["short_uvs"]:
self._cache["short_uvs"][lang] = {}
if year not in self._cache["short_uvs"][lang]:
url = f"{self.BASE_URL}/uvs/{lang}/{year}"
response = self.get(url)
uvs = ShortUvList.validate_json(response.content)
self._cache["short_uvs"][lang][year] = uvs
return self._cache["short_uvs"][lang][year]
def fetch_uvs(
self, lang: str = "fr", year: int | None = None
) -> Iterator[UvSchema]:
"""Fetch all UVs from the UTBM API, parsed in a format that we can use.
Warning:
We need infos from the full uv schema, and the UTBM UV API
has no route to get all of them at once.
We must do one request per UV (for a total of around 730 UVs),
which takes a lot of time.
Hopefully, there seems to be no rate-limit, so an error
in the middle of the process isn't likely to occur.
"""
if year is None:
year = self.current_year
shorts_uvs = self.fetch_short_uvs(lang, year)
# When UVs are common to multiple branches (like most HUMA)
# the UTBM API duplicates them for every branch.
# We have no way in our db to link a UV to multiple formations,
# so we just create a single UV, which formation is the one
# of the first UV found in the list.
# For example, if we have CC01 (TC), CC01 (IMSI) and CC01 (EDIM),
# we will only keep CC01 (TC).
unique_short_uvs = {}
for uv in shorts_uvs:
if uv.code not in unique_short_uvs:
unique_short_uvs[uv.code] = uv
for uv in unique_short_uvs.values():
uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{uv.code}/{uv.code_formation}"
response = requests.get(uv_url)
full_uv = UtbmFullUvSchema.model_validate_json(response.content)
yield make_clean_uv(uv, full_uv)
def find_uv(self, lang: str, code: str, year: int | None = None) -> UvSchema | None:
"""Find an UV from the UTBM API.""" """Find an UV from the UTBM API."""
# query the UV list # query the UV list
base_url = settings.SITH_PEDAGOGY_UTBM_API if not year:
uvs_url = f"{base_url}/uvs/{lang}/{year}" year = self.current_year
response = urllib.request.urlopen(uvs_url) # the UTBM API has no way to fetch a single short uv,
uvs: list[UtbmShortUvSchema] = ShortUvList.validate_json(response.read()) # and short uvs contain infos that we need and are not
# in the full uv schema, so we must fetch everything.
short_uv = next((uv for uv in uvs if uv.code == code), None) short_uvs = self.fetch_short_uvs(lang, year)
short_uv = next((uv for uv in short_uvs if uv.code == code), None)
if short_uv is None: if short_uv is None:
return None return None
# get detailed information about the UV # get detailed information about the UV
uv_url = f"{base_url}/uv/{lang}/{year}/{code}/{short_uv.code_formation}" uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_uv.code_formation}"
response = urllib.request.urlopen(uv_url) response = requests.get(uv_url)
full_uv = UtbmFullUvSchema.model_validate_json(response.read()) full_uv = UtbmFullUvSchema.model_validate_json(response.content)
return _make_clean_uv(short_uv, full_uv) return make_clean_uv(short_uv, full_uv)
def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema: def make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
"""Cleans the data up so that it corresponds to our data representation. """Cleans the data up so that it corresponds to our data representation.
Some of the needed information are in the short uv schema, some Some of the needed information are in the short uv schema, some
@ -61,9 +125,9 @@ def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> Uv
semester = "CLOSED" semester = "CLOSED"
return UvSchema( return UvSchema(
title=full_uv.libelle, title=full_uv.libelle or "",
code=full_uv.code, code=full_uv.code,
credit_type=short_uv.code_categorie, credit_type=short_uv.code_categorie or "FREE",
semester=semester, semester=semester,
language=short_uv.code_langue.upper(), language=short_uv.code_langue.upper(),
credits=full_uv.credits_ects, credits=full_uv.credits_ects,
@ -74,8 +138,8 @@ def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> Uv
hours_TE=next((i.nbh for i in full_uv.activites if i.code == "TE"), 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, 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 "", manager=full_uv.respo_automne or full_uv.respo_printemps or "",
objectives=full_uv.objectifs, objectives=full_uv.objectifs or "",
program=full_uv.programme, program=full_uv.programme or "",
skills=full_uv.acquisition_competences, skills=full_uv.acquisition_competences or "",
key_concepts=full_uv.acquisition_notions, key_concepts=full_uv.acquisition_notions or "",
) )

View File

@ -44,6 +44,7 @@ dependencies = [
"django-honeypot<2.0.0,>=1.2.1", "django-honeypot<2.0.0,>=1.2.1",
"pydantic-extra-types<3.0.0,>=2.10.1", "pydantic-extra-types<3.0.0,>=2.10.1",
"ical<9.0.0,>=8.3.0", "ical<9.0.0,>=8.3.0",
"requests>=2.32.3",
] ]
[project.urls] [project.urls]

6
uv.lock generated
View File

@ -155,7 +155,7 @@ name = "click"
version = "8.1.8" version = "8.1.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "platform_system == 'Windows'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
wheels = [ wheels = [
@ -744,7 +744,7 @@ version = "1.6.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "platform_system == 'Windows'" },
{ name = "ghp-import" }, { name = "ghp-import" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "markdown" }, { name = "markdown" },
@ -1437,6 +1437,7 @@ dependencies = [
{ name = "pydantic-extra-types" }, { name = "pydantic-extra-types" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "reportlab" }, { name = "reportlab" },
{ name = "requests" },
{ name = "sentry-sdk" }, { name = "sentry-sdk" },
{ name = "sphinx" }, { name = "sphinx" },
{ name = "tomli" }, { name = "tomli" },
@ -1495,6 +1496,7 @@ requires-dist = [
{ name = "pydantic-extra-types", specifier = ">=2.10.1,<3.0.0" }, { name = "pydantic-extra-types", specifier = ">=2.10.1,<3.0.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0,<3.0.0.0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0,<3.0.0.0" },
{ name = "reportlab", specifier = ">=4.2.5,<5.0.0" }, { name = "reportlab", specifier = ">=4.2.5,<5.0.0" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "sentry-sdk", specifier = ">=2.19.2,<3.0.0" }, { name = "sentry-sdk", specifier = ">=2.19.2,<3.0.0" },
{ name = "sphinx", specifier = ">=5,<6" }, { name = "sphinx", specifier = ">=5,<6" },
{ name = "tomli", specifier = ">=2.2.1,<3.0.0" }, { name = "tomli", specifier = ">=2.2.1,<3.0.0" },