Skip to content

Commit

Permalink
release 0.1.1 (#19)
Browse files Browse the repository at this point in the history
* add swagger static

* refactor Azamat's docker-compose.yaml

* add static for swagger

* add API event_black for searching without creds in modeus.py

* add search Modeus API
Complete #6 tasks

* Fix bug with empty list
  • Loading branch information
depocoder authored Sep 26, 2024
1 parent 504378f commit c519f14
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 34 deletions.
65 changes: 58 additions & 7 deletions backend/app/controllers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class ModeusCreds(BaseModel):
password: str


class ModeusSearchEvents(BaseModel):
class ModeusEventsBody(BaseModel):
"""Modeus search events body."""
size: int = Field(examples=[50], default=50)
time_min: datetime.datetime = Field(alias="timeMin", examples=[datetime.datetime.now()])
Expand All @@ -28,6 +28,17 @@ class ModeusSearchEvents(BaseModel):
attendee_person_id: list[str] = Field(alias="attendeePersonId", default="d69c87c8-aece-4f39-b6a2-7b467b968211")


class ModeusPersonSearch(BaseModel):
"""Modeus search events body."""
full_name: str = Field(alias="fullName")


class FullModeusPersonSearch(ModeusPersonSearch):
sort: str = Field(default="+fullName")
size: int = Field(default=10)
page: int = Field(default=0)


class Location(BaseModel):
id: uuid.UUID = Field(alias="eventId")
custom_location: str = Field(alias="customLocation")
Expand Down Expand Up @@ -55,25 +66,27 @@ class Href(BaseModel):
def id(self) -> uuid.UUID:
return uuid.UUID(self.href.replace('/', ''))


class Link(BaseModel):
self: Href
event: Href
person: Href


class Attender(BaseModel):
links: Link = Field(alias="_links")


class Teacher(BaseModel):
class ShortPerson(BaseModel):
id: uuid.UUID
full_name: str = Field(alias="fullName")


class Embedded(BaseModel):
class CalendarEmbedded(BaseModel):
events: list[Event] = Field(alias="events")
locations: list[Location] = Field(alias="event-locations")
attendees: list[Attender] = Field(alias="event-attendees")
teacher: list[Teacher] = Field(alias="persons")
people: list[ShortPerson] = Field(alias="persons")


class FullEvent(Event, Location):
Expand All @@ -83,11 +96,12 @@ class FullEvent(Event, Location):
class ModeusCalendar(BaseModel):
"""Modeus calendar response."""

embedded: Embedded = Field(alias="_embedded")
embedded: CalendarEmbedded = Field(alias="_embedded")

def parse_modeus_response(self) -> list[FullEvent]:
def serialize_modeus_response(self) -> list[FullEvent]:
"""Serialize calendar api response from modeus."""
locations = {location.id: location for location in self.embedded.locations}
teachers = {teacher.id: teacher for teacher in self.embedded.teacher}
teachers = {teacher.id: teacher for teacher in self.embedded.people}
teachers_with_events = {teacher.links.event.id: teacher.links for teacher in self.embedded.attendees}
full_events = []
for event in self.embedded.events:
Expand All @@ -103,3 +117,40 @@ def parse_modeus_response(self) -> list[FullEvent]:
**event.model_dump(by_alias=True), **location.model_dump(by_alias=True),
}))
return full_events


class StudentsSpeciality(BaseModel):
id: uuid.UUID = Field(alias="personId")
flow_code: Optional[str] = Field(alias="flowCode")
learning_start_date: Optional[datetime.datetime] = Field(alias="learningStartDate")
learning_end_date: Optional[datetime.datetime] = Field(alias="learningEndDate")
specialty_code: Optional[str] = Field(alias="specialtyCode")
specialty_name: Optional[str] = Field(alias="specialtyName")
specialty_profile: Optional[str] = Field(alias="specialtyProfile")


class ExtendedPerson(StudentsSpeciality, ShortPerson):
pass


class PeopleEmbedded(BaseModel):
persons: list[ShortPerson] = Field(default=[])
students: list[StudentsSpeciality] = Field(default=[])


class SearchPeople(BaseModel):
embedded: PeopleEmbedded = Field(alias="_embedded")

def serialize_modeus_response(self) -> list[ExtendedPerson]:
"""Serialize search people response."""
speciality_ids = {student.id: student for student in self.embedded.students}
extended_people = []
for person in self.embedded.persons:
try:
teacher_event = speciality_ids[person.id]
except KeyError:
continue
extended_people.append(ExtendedPerson(**{
**teacher_event.model_dump(by_alias=True), **person.model_dump(by_alias=True),
}))
return extended_people
40 changes: 36 additions & 4 deletions backend/app/controllers/modeus.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from integration import modeus
from integration.exceptions import ModeusError
from . import models
from ..settings import load_settings

settings = load_settings()

class FromAuthorizationHeader(FromHeader[str]):
name = "bearer-token"
Expand All @@ -32,13 +34,13 @@ def class_name(cls) -> str:
return "Modeus"

@post()
async def get_modeus_cookies(self, item: FromJson[models.ModeusCreds]) -> Response:
async def get_modeus_cookies(self, body: FromJson[models.ModeusCreds]) -> Response:
"""
Auth in Modeus and return cookies.
"""
try:
return self.json(
await modeus.login(item.value.username, item.value.password),
await modeus.login(body.value.username, body.value.password),
)
except (RequestException, ModeusError) as exception:
return self.json({"error": f"can't authenticate {exception}"}, status=400)
Expand All @@ -47,17 +49,47 @@ async def get_modeus_cookies(self, item: FromJson[models.ModeusCreds]) -> Respon
async def get_modeus_events(
self,
auth: FromAuthorizationHeader,
item: FromJson[models.ModeusSearchEvents],
body: FromJson[models.ModeusEventsBody],
) -> Response:
"""
Get events from Modeus.
"""
try:
jwt = auth.value.split()[1]
return self.json(await modeus.get_events(jwt, item.value))
return self.json(await modeus.get_events(jwt, body.value))
except IndexError as exception:
return self.json(
{"error": f"cannot parse authorization header {exception}"},
)
except (RequestException, ModeusError) as exception:
return self.json({"error": f"can't authenticate {exception}"}, status=400)


@post("/events_blank/")
async def get_modeus_events_blank(
self,
body: FromJson[models.ModeusEventsBody],
) -> Response:
"""
Get events from Modeus when no account.
"""
try:
jwt_token = (await modeus.login(settings.modeus_username, settings.modeus_password))['token']
except (RequestException, ModeusError) as exception:
return self.json({"error": f"can't authenticate {exception}"}, status=400)
auth = FromAuthorizationHeader(value=f"Bearer {jwt_token}")
return await self.get_modeus_events(body=body, auth=auth)

@post("/search_blank/")
async def search_blank(
self,
body: FromJson[models.ModeusPersonSearch],
) -> Response:
"""
Search people from Modeus when no account.
"""
try:
jwt_token = (await modeus.login(settings.modeus_username, settings.modeus_password))['token']
return self.json(await modeus.get_people(jwt_token, body.value))
except (RequestException, ModeusError) as exception:
return self.json({"error": f"can't authenticate {exception}"}, status=400)
9 changes: 9 additions & 0 deletions backend/app/docs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
It exposes a docs object that can be used to decorate request handlers with additional
information, used to generate OpenAPI documentation.
"""
from blacksheep.server.openapi.ui import SwaggerUIProvider, UIFilesOptions

from app.docs.binders import set_binders_docs
from app.settings import Settings
Expand All @@ -21,6 +22,14 @@ def configure_docs(app: Application, settings: Settings) -> None:
# include only endpoints whose path starts with "/api/"
docs.include = lambda path, _: path.startswith("/api/")

# CDN is too slow
docs.ui_providers = [
SwaggerUIProvider(
ui_files_options=UIFilesOptions(
'/static/swagger-ui-bundle.js', '/static/swagger-ui.css',
),
),
]
set_binders_docs(docs)

docs.bind_app(app)
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ def configure_application(
show_error_details=settings.app.show_error_details,
)

app.serve_files("app/static", root_path='/static/')
configure_error_handlers(app)
configure_authentication(app, settings)
configure_docs(app, settings)
configure_templating(app, settings)

app.use_cors(
allow_methods="*",
Expand Down
2 changes: 2 additions & 0 deletions backend/app/static/swagger-ui-bundle.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions backend/app/static/swagger-ui.css

Large diffs are not rendered by default.

54 changes: 35 additions & 19 deletions backend/integration/modeus.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
from bs4 import BeautifulSoup, Tag
from httpx import URL, AsyncClient

from app.controllers.models import ModeusSearchEvents, ModeusCalendar, FullEvent
from app.controllers.models import ModeusEventsBody, ModeusCalendar, FullEvent, ModeusPersonSearch, \
FullModeusPersonSearch, SearchPeople, ExtendedPerson
from integration.exceptions import CannotAuthenticateError, LoginFailedError


_token_re = re.compile(r"id_token=([a-zA-Z0-9\-_.]+)")
_AUTH_URL = "https://auth.modeus.org/oauth2/authorize"

Expand Down Expand Up @@ -79,12 +79,12 @@ async def login(username: str, __password: str, timeout: int = 15) -> dict[str,
CannotAuthenticateError: if something changed in API
"""
async with httpx.AsyncClient(
base_url="https://utmn.modeus.org/",
timeout=timeout,
headers={
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
},
follow_redirects=True,
base_url="https://utmn.modeus.org/",
timeout=timeout,
headers={
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0",
},
follow_redirects=True,
) as session:
form = await get_auth_form(session, username, __password)
auth_data = {}
Expand All @@ -97,7 +97,6 @@ async def login(username: str, __password: str, timeout: int = 15) -> dict[str,
follow_redirects=False,
)
headers = {"Referer": "https://fs.utmn.ru/"}
auth_id = response.cookies.get("commonAuthId")
# This auth request redirects to another URL, which redirects to Modeus home page,
# so we use HEAD in the latter one to get only target URL and extract the token
response = await session.head(response.headers["Location"], headers=headers)
Expand All @@ -106,7 +105,7 @@ async def login(username: str, __password: str, timeout: int = 15) -> dict[str,
token = _extract_token_from_url(response.url.fragment)
if token is None:
raise CannotAuthenticateError
return {"token": token, "auth_id": auth_id}
return {"token": token}


def _extract_token_from_url(url: str, match_index: int = 1) -> str | None:
Expand All @@ -116,12 +115,7 @@ def _extract_token_from_url(url: str, match_index: int = 1) -> str | None:
return match[match_index]


async def get_events(
__jwt: str,
body: ModeusSearchEvents,
timeout: int = 15,
) -> list[FullEvent]:
"""Get events for student in modeus"""
async def post_modeus(__jwt: str, body: Any, url_part: str, timeout: int = 15) -> str:
session = AsyncClient(
http2=True,
base_url="https://utmn.modeus.org/",
Expand All @@ -130,8 +124,30 @@ async def get_events(
session.headers["Authorization"] = f"Bearer {__jwt}"
session.headers["content-type"] = "application/json"
response = await session.post(
"/schedule-calendar-v2/api/calendar/events/search",
url_part,
content=body.model_dump_json(by_alias=True),
)
modeus_calendar = ModeusCalendar.model_validate_json(response.text)
return modeus_calendar.parse_modeus_response()
response.raise_for_status()
return response.text


async def get_events(
__jwt: str,
body: ModeusEventsBody,

) -> list[FullEvent]:
"""Get events for student in modeus"""
response = await post_modeus(__jwt, body, "/schedule-calendar-v2/api/calendar/events/search")
modeus_calendar = ModeusCalendar.model_validate_json(response)
return modeus_calendar.serialize_modeus_response()


async def get_people(
__jwt: str,
body: ModeusPersonSearch,
) -> list[ExtendedPerson]:
"""Get people from modeus"""
full_body = FullModeusPersonSearch(**(body.model_dump(by_alias=True)))
response = await post_modeus(__jwt, full_body, "/schedule-calendar-v2/api/people/persons/search")
search_people = SearchPeople.model_validate_json(response)
return search_people.serialize_modeus_response()
6 changes: 3 additions & 3 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ services:
environment:
APP_HOST: "0.0.0.0"
APP_PORT: "8000"
MODEUS_USERNAME: test
MODEUS_PASSWORD: test
volumes:
- ./backend:/app/
ports:
- 8000:8000
- "127.0.0.1:8000:8000"
env_file:
- backend/.env

frontend:
build:
Expand Down

0 comments on commit c519f14

Please sign in to comment.