mirror of
https://github.com/ae-utbm/sith.git
synced 2026-08-14 01:09:15 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
826400c57d | ||
|
|
79ec036b70 | ||
|
|
455b81cff6 | ||
|
|
9ceb51a54e | ||
|
|
790a1e15b1 | ||
|
|
b2ffcd3a37 | ||
|
|
ca37996d6a | ||
|
|
173311c1d5 | ||
|
|
2995823d6e |
-5
@@ -18,12 +18,7 @@ class Command(BaseCommand):
|
||||
"Fetching UEs from the UTBM API.\n"
|
||||
"This may take a few minutes to complete."
|
||||
)
|
||||
i = 0
|
||||
for ue in client.fetch_ues():
|
||||
if (i := (i + 1)) % 10 == 0:
|
||||
self.stdout.write(f"{i} UEs processed")
|
||||
# this is a N+1 query, but it isn't a problem,
|
||||
# as fetching a UE from the UTBM API is taking most of the time anyway
|
||||
db_ue = UE.objects.filter(code=ue.code).first()
|
||||
if db_ue is None:
|
||||
db_ue = UE(code=ue.code, author=root_user)
|
||||
+18
-34
@@ -5,22 +5,12 @@ from django.urls import reverse
|
||||
from django.utils import html
|
||||
from haystack.query import SearchQuerySet
|
||||
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
|
||||
from pydantic import AliasPath, BeforeValidator, ConfigDict, Field, TypeAdapter
|
||||
from pydantic import AliasPath, ConfigDict, Field, TypeAdapter
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
from pedagogy.models import UE
|
||||
|
||||
|
||||
class FormationSchema(Schema):
|
||||
model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
|
||||
|
||||
code: str
|
||||
label: str = Field(alias="libelle")
|
||||
|
||||
|
||||
FormationListSchema = TypeAdapter(list[FormationSchema])
|
||||
|
||||
|
||||
class UtbmShortUeSchema(Schema):
|
||||
"""Short representation of an UE in the UTBM API.
|
||||
|
||||
@@ -29,17 +19,19 @@ class UtbmShortUeSchema(Schema):
|
||||
The UTBM API returns more data than that.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(validate_by_name=True)
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
code: str
|
||||
category: str = Field(alias="codeCategorie")
|
||||
formation: FormationSchema
|
||||
lang: str = Field(alias="codeLangue")
|
||||
open_autumn: bool = Field(alias="ouvertAutomne")
|
||||
open_spring: bool = Field(alias="ouvertPrintemps")
|
||||
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
|
||||
|
||||
@@ -56,30 +48,22 @@ class SemesterUeState(Schema):
|
||||
ShortUeList = TypeAdapter(list[UtbmShortUeSchema])
|
||||
|
||||
|
||||
class SyllabusItemSchema(Schema):
|
||||
model_config = ConfigDict(validate_by_name=True)
|
||||
|
||||
label: str = Field(alias="libelle")
|
||||
# We want only strings, but the UTBM API can return null,
|
||||
# so convert null values to empty strings
|
||||
value: Annotated[str, BeforeValidator(lambda x: x or "")] = Field(alias="valeur")
|
||||
|
||||
|
||||
class UtbmFullUeSchema(Schema):
|
||||
"""Long representation of an UE in the UTBM API."""
|
||||
|
||||
model_config = ConfigDict(validate_by_name=True, alias_generator=to_camel)
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
code: str
|
||||
departement: str = "NA"
|
||||
label: str = Field(alias="libelle")
|
||||
syllabus: list[SyllabusItemSchema] = Field(
|
||||
default=[],
|
||||
validation_alias=AliasPath("listeElementConstitutif", 0, "listeSaisieSyllabus"),
|
||||
)
|
||||
lang: str = Field(None, validation_alias=AliasPath("langueEnseignement", "code"))
|
||||
libelle: str | None
|
||||
objectifs: str | None
|
||||
programme: str | None
|
||||
acquisition_competences: str | None
|
||||
acquisition_notions: str | None
|
||||
langue: str
|
||||
code_langue: str
|
||||
credits_ects: int
|
||||
activites: list[WorkloadSchema] = Field(alias="listeActivite")
|
||||
activites: list[WorkloadSchema]
|
||||
respo_automne: str | None = Field(
|
||||
None, validation_alias=AliasPath("automne", "responsable")
|
||||
)
|
||||
|
||||
+36
-81
@@ -5,16 +5,8 @@ from typing import Iterator
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.utils.functional import cached_property
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from pedagogy.schemas import (
|
||||
FormationSchema,
|
||||
UeSchema,
|
||||
UtbmFullUeSchema,
|
||||
UtbmShortUeSchema,
|
||||
)
|
||||
|
||||
FormationListSchema = TypeAdapter(list[FormationSchema])
|
||||
from pedagogy.schemas import ShortUeList, UeSchema, UtbmFullUeSchema, UtbmShortUeSchema
|
||||
|
||||
|
||||
class UtbmApiClient(requests.Session):
|
||||
@@ -26,44 +18,28 @@ class UtbmApiClient(requests.Session):
|
||||
@cached_property
|
||||
def current_year(self) -> int:
|
||||
"""Fetch from the API the latest existing year"""
|
||||
url = f"{self.BASE_URL}/guide/"
|
||||
response = self.get(url, {"langue": "fr"})
|
||||
return max(i["annee"] for i in response.json())
|
||||
url = f"{self.BASE_URL}/guides/fr"
|
||||
response = self.get(url)
|
||||
return response.json()[-1]["annee"]
|
||||
|
||||
@cached_property
|
||||
def formations(self) -> list[FormationSchema]:
|
||||
response = self.get(
|
||||
f"{self.BASE_URL}/formation/",
|
||||
{"langue": "fr", "annee_univ": self.current_year, "typeFormation": "ING"},
|
||||
)
|
||||
return FormationListSchema.validate_json(response.text, by_alias=True)
|
||||
|
||||
def fetch_short_ues(self, year: int | None = None) -> list[UtbmShortUeSchema]:
|
||||
def fetch_short_ues(
|
||||
self, lang: str = "fr", year: int | None = None
|
||||
) -> list[UtbmShortUeSchema]:
|
||||
"""Get the list of UEs in their short format from the UTBM API"""
|
||||
if year is None:
|
||||
year = self.current_year
|
||||
if year not in self._cache["short_ues"]:
|
||||
ues = []
|
||||
for formation in self.formations:
|
||||
response = self.get(
|
||||
f"{self.BASE_URL}/ue/",
|
||||
{
|
||||
"langue": "fr",
|
||||
"annee_univ": year,
|
||||
"codeFormation": formation.code,
|
||||
},
|
||||
)
|
||||
ues.extend(
|
||||
[
|
||||
UtbmShortUeSchema.model_validate({**ue, "formation": formation})
|
||||
for ue in response.json()
|
||||
if ue["codeCategorie"] is not None
|
||||
]
|
||||
)
|
||||
self._cache["short_ues"][year] = ues
|
||||
return self._cache["short_ues"][year]
|
||||
if lang not in self._cache["short_ues"]:
|
||||
self._cache["short_ues"][lang] = {}
|
||||
if year not in self._cache["short_ues"][lang]:
|
||||
url = f"{self.BASE_URL}/uvs/{lang}/{year}"
|
||||
response = self.get(url)
|
||||
ues = ShortUeList.validate_json(response.content)
|
||||
self._cache["short_ues"][lang][year] = ues
|
||||
return self._cache["short_ues"][lang][year]
|
||||
|
||||
def fetch_ues(self, year: int | None = None) -> Iterator[UeSchema]:
|
||||
def fetch_ues(
|
||||
self, lang: str = "fr", year: int | None = None
|
||||
) -> Iterator[UeSchema]:
|
||||
"""Fetch all UEs from the UTBM API, parsed in a format that we can use.
|
||||
|
||||
Warning:
|
||||
@@ -76,7 +52,7 @@ class UtbmApiClient(requests.Session):
|
||||
"""
|
||||
if year is None:
|
||||
year = self.current_year
|
||||
shorts_ues = self.fetch_short_ues(year)
|
||||
shorts_ues = self.fetch_short_ues(lang, year)
|
||||
# When UEs 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 UE to multiple formations,
|
||||
@@ -89,40 +65,27 @@ class UtbmApiClient(requests.Session):
|
||||
if ue.code not in unique_short_ues:
|
||||
unique_short_ues[ue.code] = ue
|
||||
for ue in unique_short_ues.values():
|
||||
if ue.lang.upper() != "FR":
|
||||
continue
|
||||
response = requests.get(
|
||||
f"{self.BASE_URL}/ue/{ue.code}",
|
||||
{
|
||||
"langue": ue.lang,
|
||||
"annee_univ": year,
|
||||
"codeFormation": ue.formation.code,
|
||||
},
|
||||
)
|
||||
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{ue.code}/{ue.code_formation}"
|
||||
response = requests.get(ue_url)
|
||||
full_ue = UtbmFullUeSchema.model_validate_json(response.content)
|
||||
yield make_clean_ue(ue, full_ue)
|
||||
|
||||
def find_ue(self, code: str, year: int | None = None) -> UeSchema | None:
|
||||
"""Find a UE from the UTBM API."""
|
||||
def find_uu(self, lang: str, code: str, year: int | None = None) -> UeSchema | None:
|
||||
"""Find an UE from the UTBM API."""
|
||||
# query the UE list
|
||||
if not year:
|
||||
year = self.current_year
|
||||
# the UTBM API has no way to fetch a single short ue,
|
||||
# and short ues contain infos that we need and are not
|
||||
# in the full ue schema, so we must fetch everything.
|
||||
short_ues = self.fetch_short_ues(year)
|
||||
short_ues = self.fetch_short_ues(lang, year)
|
||||
short_ue = next((ue for ue in short_ues if ue.code == code), None)
|
||||
if short_ue is None:
|
||||
return None
|
||||
|
||||
# get detailed information about the UE
|
||||
response = requests.get(
|
||||
f"{self.BASE_URL}/ue/{code}",
|
||||
{
|
||||
"langue": "fr",
|
||||
"annee_univ": year,
|
||||
"codeFormation": short_ue.formation.code,
|
||||
},
|
||||
)
|
||||
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_ue.code_formation}"
|
||||
response = requests.get(ue_url)
|
||||
full_ue = UtbmFullUeSchema.model_validate_json(response.content)
|
||||
return make_clean_ue(short_ue, full_ue)
|
||||
|
||||
@@ -149,9 +112,9 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
|
||||
"ED": "EDIM",
|
||||
"AI": "GI",
|
||||
"AM": "MC",
|
||||
}.get(short_ue.formation.code, "NA")
|
||||
}.get(short_ue.code_formation, "NA")
|
||||
|
||||
match short_ue.open_spring, short_ue.open_autumn:
|
||||
match short_ue.ouvert_printemps, short_ue.ouvert_automne:
|
||||
case True, True:
|
||||
semester = "AUTUMN_AND_SPRING"
|
||||
case True, False:
|
||||
@@ -162,11 +125,11 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
|
||||
semester = "CLOSED"
|
||||
|
||||
return UeSchema(
|
||||
title=full_ue.label or "",
|
||||
title=full_ue.libelle or "",
|
||||
code=full_ue.code,
|
||||
credit_type=short_ue.category or "FREE",
|
||||
credit_type=short_ue.code_categorie or "FREE",
|
||||
semester=semester,
|
||||
language=short_ue.lang.upper(),
|
||||
language=short_ue.code_langue.upper(),
|
||||
credits=full_ue.credits_ects,
|
||||
department=department,
|
||||
hours_THE=next((i.nbh for i in full_ue.activites if i.code == "THE"), 0) // 60,
|
||||
@@ -175,16 +138,8 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
|
||||
hours_TE=next((i.nbh for i in full_ue.activites if i.code == "TE"), 0) // 60,
|
||||
hours_CM=next((i.nbh for i in full_ue.activites if i.code == "CM"), 0) // 60,
|
||||
manager=full_ue.respo_automne or full_ue.respo_printemps or "",
|
||||
objectives=next(
|
||||
(i.value for i in full_ue.syllabus if i.label == "Objectifs"), ""
|
||||
),
|
||||
program=next((i.value for i in full_ue.syllabus if i.label == "Programme"), ""),
|
||||
skills=next(
|
||||
(i.value for i in full_ue.syllabus if i.label == "Acquis d'apprentissage"),
|
||||
"",
|
||||
),
|
||||
key_concepts=next(
|
||||
(i.value for i in full_ue.syllabus if i.label == "Notions-clés"),
|
||||
"",
|
||||
),
|
||||
objectives=full_ue.objectifs or "",
|
||||
program=full_ue.programme or "",
|
||||
skills=full_ue.acquisition_competences or "",
|
||||
key_concepts=full_ue.acquisition_notions or "",
|
||||
)
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ authors = [
|
||||
license = { text = "GPL-3.0-only" }
|
||||
requires-python = "<4.0,>=3.12"
|
||||
dependencies = [
|
||||
"django>=5.2.15,<6.0.0",
|
||||
"django>=5.2.16,<6.0.0",
|
||||
"django-ninja>=1.6.2,<2.0.0",
|
||||
"django-ninja-extra>=0.31.5",
|
||||
"Pillow>=12.2.0,<13.0.0",
|
||||
|
||||
+1
-1
@@ -493,7 +493,7 @@ SITH_LOG_OPERATION_TYPE = [
|
||||
("REFILLING_DELETION", _("Refilling deletion")),
|
||||
]
|
||||
|
||||
SITH_PEDAGOGY_UTBM_API = "https://api.utbm.fr/offre-formation/public"
|
||||
SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide"
|
||||
|
||||
# Defines pagination for cash summary
|
||||
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
|
||||
|
||||
@@ -590,16 +590,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.15"
|
||||
version = "5.2.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/e3/31722f7284c9f43333daff9aee9184678e4487adcb5506af0db8cea09ce1/django-5.2.15.tar.gz", hash = "sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7", size = 10873669, upload-time = "2026-06-03T13:03:35.892Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/38140b1643c00d5c46ce69c78e6980fd285aee223100319631bedee4f5e7/django-5.2.15-py3-none-any.whl", hash = "sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970", size = 8311957, upload-time = "2026-06-03T13:03:31.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -972,9 +972,9 @@ resolution-markers = [
|
||||
"python_full_version < '3.13'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "pydantic", marker = "python_full_version < '3.13'" },
|
||||
{ name = "python-dateutil", marker = "python_full_version < '3.13'" },
|
||||
{ name = "tzdata", marker = "python_full_version < '3.13'" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/a3/506fe8d9416472588ff30d4c97a7e477f680a098c74a9f2050bd84182a75/ical-12.0.0.tar.gz", hash = "sha256:2f1c7450bd1eff586322273360f44931eb0919dc048c7284f1647037624be7c4", size = 125412, upload-time = "2025-11-13T06:00:04.95Z" }
|
||||
wheels = [
|
||||
@@ -989,9 +989,9 @@ resolution-markers = [
|
||||
"python_full_version >= '3.13'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "pydantic", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "python-dateutil", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "tzdata", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/1c/dc7378c5ba63e8b0f2987bd827a1e40bd27196d37a7082be0593551e8ed8/ical-13.2.5.tar.gz", hash = "sha256:1cc3116e6f522eeeaa694b3e20b58dbfbe4b281954a8d3d28892cfb61f03fd40", size = 131405, upload-time = "2026-05-24T16:51:56.017Z" }
|
||||
wheels = [
|
||||
@@ -2187,7 +2187,7 @@ requires-dist = [
|
||||
{ name = "celery", extras = ["redis"], specifier = ">=5.6.3,<8" },
|
||||
{ name = "cryptography", specifier = ">=49.0.0,<50.0.0" },
|
||||
{ name = "dict2xml", specifier = ">=1.7.8,<2.0.0" },
|
||||
{ name = "django", specifier = ">=5.2.15,<6.0.0" },
|
||||
{ name = "django", specifier = ">=5.2.16,<6.0.0" },
|
||||
{ name = "django-celery-beat", specifier = ">=2.9.0" },
|
||||
{ name = "django-celery-results", specifier = ">=2.6.0" },
|
||||
{ name = "django-countries", specifier = ">=9.0.0,<10.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user