diff --git a/.github/workflows/build-and-deploy.yaml b/.github/workflows/build-and-deploy.yaml index ca3b079..67b3b68 100644 --- a/.github/workflows/build-and-deploy.yaml +++ b/.github/workflows/build-and-deploy.yaml @@ -16,6 +16,12 @@ env: SSH_USER: ${{ secrets.SSH_USER }} SSH_PORT: ${{ secrets.SSH_PORT }} + S3_ENDPOINT_URL: ${{ secrets.S3_ENDPOINT_URL }} + S3_REGION: ${{ secrets.S3_REGION }} + BUCKET_NAME: ${{ secrets.BUCKET_NAME }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ACCESS_KEY: ${{ secrets.ACCESS_KEY }} + jobs: build: @@ -38,13 +44,17 @@ jobs: run: | docker buildx build --platform linux/amd64,linux/arm64 --push ./backend \ --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:latest \ - --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:$IMAGE_TAG + --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:$IMAGE_TAG \ + --cache-to type=s3,endpoint_url=$S3_ENDPOINT_URL,region=$S3_REGION,bucket=$BUCKET_NAME,name=calendar_backend,access_key_id=$ACCESS_KEY,secret_access_key=$SECRET_KEY \ + --cache-from type=s3,endpoint_url=$S3_ENDPOINT_URL,region=$S3_REGION,bucket=$BUCKET_NAME,name=calendar_backend,access_key_id=$ACCESS_KEY,secret_access_key=$SECRET_KEY - name: Build & Publish frontend to Github Container registry run: | docker buildx build --platform linux/amd64,linux/arm64 --push ./frontend \ --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:latest \ - --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:$IMAGE_TAG + --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:$IMAGE_TAG \ + --cache-to type=s3,endpoint_url=$S3_ENDPOINT_URL,region=$S3_REGION,bucket=$BUCKET_NAME,name=calendar_frontend,access_key_id=$ACCESS_KEY,secret_access_key=$SECRET_KEY \ + --cache-from type=s3,endpoint_url=$S3_ENDPOINT_URL,region=$S3_REGION,bucket=$BUCKET_NAME,name=calendar_frontend,access_key_id=$ACCESS_KEY,secret_access_key=$SECRET_KEY deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0f864a8..9537e78 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -13,6 +13,12 @@ env: REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} IMAGE_TAG: ${{ github.sha }} + S3_ENDPOINT_URL: ${{ secrets.S3_ENDPOINT_URL }} + S3_REGION: ${{ secrets.S3_REGION }} + BUCKET_NAME: ${{ secrets.BUCKET_NAME }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ACCESS_KEY: ${{ secrets.ACCESS_KEY }} + jobs: build: runs-on: ubuntu-latest @@ -33,11 +39,13 @@ jobs: - name: Build & Publish backend to Github Container registry run: | docker buildx build --platform linux/amd64,linux/arm64 --push ./backend \ - --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:latest \ - --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:$IMAGE_TAG + --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:$IMAGE_TAG \ + --cache-to type=s3,endpoint_url=$S3_ENDPOINT_URL,region=$S3_REGION,bucket=$BUCKET_NAME,name=calendar_backend,access_key_id=$ACCESS_KEY,secret_access_key=$SECRET_KEY \ + --cache-from type=s3,endpoint_url=$S3_ENDPOINT_URL,region=$S3_REGION,bucket=$BUCKET_NAME,name=calendar_backend,access_key_id=$ACCESS_KEY,secret_access_key=$SECRET_KEY - name: Build & Publish frontend to Github Container registry run: | docker buildx build --platform linux/amd64,linux/arm64 --push ./frontend \ - --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:latest \ - --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:$IMAGE_TAG + --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:$IMAGE_TAG \ + --cache-to type=s3,endpoint_url=$S3_ENDPOINT_URL,region=$S3_REGION,bucket=$BUCKET_NAME,name=calendar_frontend,access_key_id=$ACCESS_KEY,secret_access_key=$SECRET_KEY \ + --cache-from type=s3,endpoint_url=$S3_ENDPOINT_URL,region=$S3_REGION,bucket=$BUCKET_NAME,name=calendar_frontend,access_key_id=$ACCESS_KEY,secret_access_key=$SECRET_KEY diff --git a/backend/README.md b/backend/README.md index c4fd4c0..fb906e8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -13,6 +13,14 @@ This project was created to replace Modeus/Netology calendars +## Features + +* Export to .ics calendar format +* Your timezone support (default Moscow) +* Modeus + Netology integration +* LMS support (not required to use) +* Redis cache + ## Getting started 1. Install [poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) diff --git a/backend/yet_another_calendar/web/api/bulk/integration.py b/backend/yet_another_calendar/web/api/bulk/integration.py index e35d8fd..214292e 100644 --- a/backend/yet_another_calendar/web/api/bulk/integration.py +++ b/backend/yet_another_calendar/web/api/bulk/integration.py @@ -12,8 +12,10 @@ from yet_another_calendar.settings import settings from ..netology import views as netology_views +from ..lms import views as lms_views from ..modeus import views as modeus_views from ..modeus import schema as modeus_schema +from ..lms import schema as lms_schema from ..netology import schema as netology_schema from . import schema @@ -22,17 +24,16 @@ def create_ics_event(title: str, starts_at: datetime.datetime, ends_at: datetime.datetime, lesson_id: Any, description: Optional[str] = None, - webinar_url: Optional[str] = None) -> icalendar.Event: + url: Optional[str] = None) -> icalendar.Event: event = icalendar.Event() dt_now = datetime.datetime.now() event.add('summary', title) - event.add('location', webinar_url) + event.add('location', url if url else 'unknown location') event.add('dtstart', starts_at) event.add('dtend', ends_at) event.add('dtstamp', dt_now) event.add('uid', lesson_id) - if description: - event.add('description', description) + event.add('DESCRIPTION', description) return event @@ -44,30 +45,36 @@ def export_to_ics(calendar: schema.CalendarResponse) -> Iterable[bytes]: for netology_lesson in calendar.netology.webinars: if not netology_lesson.starts_at or not netology_lesson.ends_at: continue - event = create_ics_event(title=f"Netology: {netology_lesson.title}", starts_at=netology_lesson.starts_at, + event = create_ics_event(title=f"Netology: {netology_lesson.block_title}", starts_at=netology_lesson.starts_at, ends_at=netology_lesson.ends_at, lesson_id=netology_lesson.id, - webinar_url=netology_lesson.webinar_url) + description=netology_lesson.title, + url=netology_lesson.webinar_url) ics_calendar.add_component(event) - for modeus_lesson in calendar.modeus: - event = create_ics_event(title=f"Modeus: {modeus_lesson.name}", - starts_at=modeus_lesson.start_time, ends_at=modeus_lesson.end_time, - lesson_id=modeus_lesson.id, - description=modeus_lesson.description) + for modeus_lesson in calendar.utmn.modeus_events: + event = create_ics_event(title=f"Modeus: {modeus_lesson.course_name}", starts_at=modeus_lesson.start_time, + ends_at=modeus_lesson.end_time, lesson_id=modeus_lesson.id, + description=modeus_lesson.name) + ics_calendar.add_component(event) + for lms_event in calendar.utmn.lms_events: + dt_start = lms_event.dt_end - datetime.timedelta(hours=2) + event = create_ics_event(title=f"LMS: {lms_event.course_name}", starts_at=dt_start, ends_at=lms_event.dt_end, + lesson_id=lms_event.id, description=lms_event.name, url=lms_event.url) ics_calendar.add_component(event) yield ics_calendar.to_ical() async def refresh_events( body: modeus_schema.ModeusEventsBody, + lms_user: lms_schema.User, jwt_token: str, calendar_id: int, cookies: netology_schema.NetologyCookies, timezone: str, ) -> schema.RefreshedCalendarResponse: """Clear events cache.""" - cached_json = await get_cached_calendar(body, jwt_token, calendar_id, cookies, timezone) + cached_json = await get_cached_calendar(body, lms_user, jwt_token, calendar_id, cookies, timezone) cached_calendar = schema.CalendarResponse.model_validate(cached_json) - calendar = await get_calendar(body, jwt_token, calendar_id, cookies, timezone) + calendar = await get_calendar(body, lms_user, jwt_token, calendar_id, cookies, timezone) changed = cached_calendar.get_hash() != calendar.get_hash() try: cache_key = default_key_builder(get_cached_calendar, args=(body, jwt_token, calendar_id, cookies), kwargs={}) @@ -87,6 +94,7 @@ async def refresh_events( async def get_calendar( body: modeus_schema.ModeusEventsBody, + lms_user: lms_schema.User, jwt_token: str, calendar_id: int, cookies: netology_schema.NetologyCookies, @@ -96,21 +104,28 @@ async def get_calendar( tz = pytz.timezone(timezone) except pytz.exceptions.UnknownTimeZoneError: raise HTTPException(detail="Wrong timezone", status_code=status.HTTP_400_BAD_REQUEST) from None - + lms_response = None async with asyncio.TaskGroup() as tg: netology_response = tg.create_task(netology_views.get_calendar(body, calendar_id, cookies)) modeus_response = tg.create_task(modeus_views.get_calendar(body, jwt_token)) + if lms_user.is_enabled: + lms_response = tg.create_task(lms_views.get_events(lms_user, body)) + lms_events = lms_response.result() if lms_response else [] return schema.CalendarResponse.model_validate( - {"netology": netology_response.result(), "modeus": modeus_response.result()}, + {"netology": netology_response.result(), "utmn": { + "modeus_events": modeus_response.result(), + "lms_events": lms_events, + }}, ).change_timezone(tz) @cache(expire=settings.redis_events_time_live) async def get_cached_calendar( body: modeus_schema.ModeusEventsBody, + lms_user: lms_schema.User, jwt_token: str, calendar_id: int, cookies: netology_schema.NetologyCookies, timezone: str, ) -> schema.CalendarResponse: - return await get_calendar(body, jwt_token, calendar_id, cookies, timezone) + return await get_calendar(body, lms_user, jwt_token, calendar_id, cookies, timezone) diff --git a/backend/yet_another_calendar/web/api/bulk/schema.py b/backend/yet_another_calendar/web/api/bulk/schema.py index 3f16f32..cefcb85 100644 --- a/backend/yet_another_calendar/web/api/bulk/schema.py +++ b/backend/yet_another_calendar/web/api/bulk/schema.py @@ -5,12 +5,20 @@ from pydantic import BaseModel, Field from ..modeus import schema as modeus_schema +from ..lms import schema as lms_schema from ..netology import schema as netology_schema +def now_dt_utc() -> datetime.datetime: + return datetime.datetime.now(tz=datetime.timezone.utc) + +class UtmnResponse(BaseModel): + modeus_events: list[modeus_schema.FullEvent] + lms_events: list[lms_schema.ModuleResponse] + class BulkResponse(BaseModel): netology: netology_schema.SerializedEvents - modeus: list[modeus_schema.FullEvent] + utmn: UtmnResponse def change_timezone(self, timezone: datetime.tzinfo) -> Self: for homework in self.netology.homework: @@ -20,13 +28,18 @@ def change_timezone(self, timezone: datetime.tzinfo) -> Self: webinar.starts_at = webinar.validate_starts_at(webinar.starts_at, timezone) webinar.ends_at = webinar.validate_ends_at(webinar.ends_at, timezone) - for event in self.modeus: - event.start_time = event.validate_starts_at(event.start_time, timezone) - event.end_time = event.validate_end_time(event.end_time, timezone) + for modeus_event in self.utmn.modeus_events: + modeus_event.start_time = modeus_event.validate_starts_at(modeus_event.start_time, timezone) + modeus_event.end_time = modeus_event.validate_end_time(modeus_event.end_time, timezone) + + for lms_event in self.utmn.lms_events: + lms_event.dt_start = lms_event.dt_start.astimezone(timezone) + lms_event.dt_end = lms_event.dt_end.astimezone(timezone) return self + class CalendarResponse(BulkResponse): - cached_at: datetime.datetime = Field(default_factory=datetime.datetime.now, alias="cached_at") + cached_at: datetime.datetime = Field(default_factory=now_dt_utc, alias="cached_at") def get_hash(self) -> str: dump = BulkResponse(**self.model_dump(by_alias=True)).model_dump_json(by_alias=True) diff --git a/backend/yet_another_calendar/web/api/bulk/views.py b/backend/yet_another_calendar/web/api/bulk/views.py index 035d0fb..a2d7295 100644 --- a/backend/yet_another_calendar/web/api/bulk/views.py +++ b/backend/yet_another_calendar/web/api/bulk/views.py @@ -9,6 +9,7 @@ from yet_another_calendar.settings import settings from ..modeus import schema as modeus_schema +from ..lms import schema as lms_schema from ..netology import schema as netology_schema from . import integration, schema @@ -18,6 +19,7 @@ @router.post("/events/") async def get_calendar( body: modeus_schema.ModeusEventsBody, + lms_user: lms_schema.User, cookies: Annotated[netology_schema.NetologyCookies, Depends(netology_schema.get_cookies_from_headers)], jwt_token: Annotated[str, Depends(modeus_schema.get_cookies_from_headers)], calendar_id: int = settings.netology_default_course_id, @@ -27,13 +29,14 @@ async def get_calendar( Get events from Netology and Modeus, cached. """ - cached_calendar = await integration.get_cached_calendar(body, jwt_token, calendar_id, cookies, time_zone) + cached_calendar = await integration.get_cached_calendar(body, lms_user, jwt_token, calendar_id, cookies, time_zone) return schema.CalendarResponse.model_validate(cached_calendar) @router.post("/refresh_events/") async def refresh_calendar( body: modeus_schema.ModeusEventsBody, + lms_user: lms_schema.User, cookies: Annotated[netology_schema.NetologyCookies, Depends(netology_schema.get_cookies_from_headers)], jwt_token: Annotated[str, Depends(modeus_schema.get_cookies_from_headers)], calendar_id: int = settings.netology_default_course_id, @@ -43,12 +46,13 @@ async def refresh_calendar( Refresh events in redis. """ - return await integration.refresh_events(body, jwt_token, calendar_id, cookies, time_zone) + return await integration.refresh_events(body, lms_user, jwt_token, calendar_id, cookies, time_zone) @router.post("/export_ics/") async def export_ics( body: modeus_schema.ModeusEventsBody, + lms_user: lms_schema.User, cookies: Annotated[netology_schema.NetologyCookies, Depends(netology_schema.get_cookies_from_headers)], jwt_token: Annotated[str, Depends(modeus_schema.get_cookies_from_headers)], calendar_id: int = settings.netology_default_course_id, @@ -57,5 +61,5 @@ async def export_ics( """ Export into .ics format """ - calendar = await integration.get_calendar(body, jwt_token, calendar_id, cookies, time_zone) + calendar = await integration.get_calendar(body, lms_user, jwt_token, calendar_id, cookies, time_zone) return StreamingResponse(integration.export_to_ics(calendar)) diff --git a/backend/yet_another_calendar/web/api/lms/__init__.py b/backend/yet_another_calendar/web/api/lms/__init__.py new file mode 100644 index 0000000..7038649 --- /dev/null +++ b/backend/yet_another_calendar/web/api/lms/__init__.py @@ -0,0 +1,3 @@ +from .views import router + +__all__ = ["router"] diff --git a/backend/yet_another_calendar/web/api/lms/integration.py b/backend/yet_another_calendar/web/api/lms/integration.py new file mode 100644 index 0000000..0cd20b3 --- /dev/null +++ b/backend/yet_another_calendar/web/api/lms/integration.py @@ -0,0 +1,127 @@ +"""Netology API implementation.""" +import asyncio +from typing import Any + +import httpx +import reretry +from fastapi import HTTPException +from httpx import AsyncClient +from pydantic import TypeAdapter +from starlette import status + +from . import schema +from yet_another_calendar.settings import settings +from ..modeus.schema import ModeusTimeBody + + +@reretry.retry(exceptions=httpx.TransportError, tries=settings.retry_tries, delay=settings.retry_delay) +async def get_token(creds: schema.LxpCreds, timeout: int = 15) -> str: + """ + Auth in lms, required username and password. + """ + session = AsyncClient( + http2=True, + base_url="https://lms.utmn.ru", + timeout=timeout, + ) + response = await session.post('/login/token.php', data=creds.model_dump()) + response.raise_for_status() + serialized_response = response.json() + error = serialized_response.get('error') or serialized_response.get('exception') + if error: + raise HTTPException(detail=f'{error}. Server response: {serialized_response}', + status_code=response.status_code) + return serialized_response['token'] + + +@reretry.retry(exceptions=httpx.TransportError, tries=settings.retry_tries, delay=settings.retry_delay) +async def send_request( + request_settings: dict[str, Any], timeout: int = 15) -> dict[str, Any] | list[dict[str, Any]]: + """Send request from httpx.""" + session = AsyncClient( + http2=True, + base_url="https://lms.utmn.ru", + timeout=timeout, + ) + response = await session.request(**request_settings) + response.raise_for_status() + serialized_response = response.json() + if isinstance(serialized_response, list): + return serialized_response + error = serialized_response.get('error') or serialized_response.get('exception') + if error: + raise HTTPException(detail=f'{error}. Server response: {serialized_response}', + status_code=status.HTTP_400_BAD_REQUEST) + return serialized_response + + +async def get_user_info(token: str, username: str) -> list[dict[str, Any]]: + response = await send_request( + request_settings={ + 'method': 'POST', + 'url': '/webservice/rest/server.php', + 'params': {"wsfunction": "core_user_get_users_by_field", + "field": "username", + "values[0]": username, + "wstoken": token, + "moodlewsrestformat": "json"}, + }) + return response + + +async def auth_lms(creds: schema.LxpCreds) -> schema.User: + """Get token and username""" + token = await get_token(creds) + user_info = await get_user_info(token, creds.get_username()) + return schema.User(**user_info[0], token=token) + + +async def get_courses(user: schema.User) -> list[schema.Course]: + """Get courses.""" + response = await send_request( + request_settings={ + 'method': 'GET', + 'url': '/webservice/rest/server.php', + 'params': { + 'wstoken': user.token, + 'moodlewsrestformat': 'json', + 'wsfunction': 'core_enrol_get_users_courses', + 'userid': user.id, + }, + }) + adapter = TypeAdapter(list[schema.Course]) + return adapter.validate_python(response) + + +async def get_extended_course(user: schema.User, course_id: int) -> list[schema.ExtendedCourse]: + """Get extended course with modules and deadlines.""" + response = await send_request( + request_settings={ + 'method': 'POST', + 'url': '/webservice/rest/server.php', + 'params': { + 'wstoken': user.token, + 'wsfunction': 'core_course_get_contents', + 'courseid': course_id, + 'moodlewsrestformat': 'json', + }, + }) + adapter = TypeAdapter(list[schema.ExtendedCourse]) + return adapter.validate_python(response) + + +async def get_filtered_courses(user: schema.User, body: ModeusTimeBody) -> list[schema.ModuleResponse]: + """Filter LXP events.""" + courses = await get_courses(user) + course_by_ids = {course.id: course for course in courses} + tasks = {} + async with asyncio.TaskGroup() as tg: + for course in courses: + tasks[course.id] = tg.create_task(get_extended_course(user, course.id)) + filtered_modules = [] + for course_id, task in tasks.items(): + course_name = course_by_ids[course_id].full_name + extended_course = task.result() + for module in extended_course: + filtered_modules.extend(module.get_filtered_modules(body, course_name)) + return filtered_modules diff --git a/backend/yet_another_calendar/web/api/lms/schema.py b/backend/yet_another_calendar/web/api/lms/schema.py new file mode 100644 index 0000000..7990601 --- /dev/null +++ b/backend/yet_another_calendar/web/api/lms/schema.py @@ -0,0 +1,107 @@ +import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field, model_validator + +from yet_another_calendar.web.api.modeus.schema import Creds, ModeusTimeBody + + +class LxpCreds(Creds): + service: str = "test" + + def get_username(self) -> str: + return self.username.split("@")[0] + + +class User(BaseModel): + id: int + token: str + is_enabled: bool = False + + +class Course(BaseModel): + id: int + short_name: str = Field(alias="shortname") + full_name: str = Field(alias="fullname") + completed: bool + hidden: bool + + +class ModuleState(BaseModel): + state: bool + + +class DateModule(BaseModel): + label: str + date: datetime.datetime = Field(alias="timestamp") + dataid: str + + @model_validator(mode='before') + @classmethod + def deadline_validation(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + timestamp = data.get('timestamp') + if timestamp is None: + return data + data['timestamp'] = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc) + return data + + +class BaseModule(BaseModel): + id: int + url: Optional[str] = Field(default=None) + name: str + user_visible: bool = Field(alias="uservisible") + modname: str + + +class Module(BaseModule): + dates: list[DateModule] + completion_state: Optional[ModuleState] = Field(alias="completiondata", default=None) + + +class ModuleResponse(BaseModule): + dt_start: datetime.datetime + dt_end: datetime.datetime + is_completed: bool + course_name: str + + +class ExtendedCourse(BaseModel): + id: int + name: str + modules: list[Module] + + @staticmethod + def is_suitable_time(deadline: datetime.datetime, + time_min: datetime.datetime, time_max: datetime.datetime) -> bool: + """Check if lesson have suitable time""" + if deadline and time_max > deadline > time_min: + return True + return False + + def get_filtered_modules(self, body: ModeusTimeBody, course_name: str) -> list[ModuleResponse]: + """Filter module by time and user_visible.""" + filtered_modules = [] + for module in self.modules: + dt_end = None + dt_start = None + if module.dates and len(module.dates) > 1: + dt_start = module.dates[0].date + dt_end = module.dates[1].date + else: + continue + if self.is_suitable_time(dt_end, body.time_min, body.time_max) and module.user_visible: + completion_state = module.completion_state + state = False + if completion_state: + state = completion_state.state + filtered_modules.append(ModuleResponse( + **module.model_dump(by_alias=True), + is_completed=state, + dt_end=dt_end, dt_start=dt_start, + course_name=course_name, + + )) + return filtered_modules diff --git a/backend/yet_another_calendar/web/api/lms/views.py b/backend/yet_another_calendar/web/api/lms/views.py new file mode 100644 index 0000000..e26089c --- /dev/null +++ b/backend/yet_another_calendar/web/api/lms/views.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter + +from . import integration, schema +from ..modeus.schema import ModeusTimeBody + +router = APIRouter() + + +@router.post("/auth") +async def get_netology_cookies( + creds: schema.LxpCreds, +) -> schema.User: + """ + Auth in LXP and return token. + """ + return await integration.auth_lms(creds) + + +@router.post("/courses") +async def get_courses( + user: schema.User, +) -> list[schema.Course]: + """ + Get LMS courses for current user. + """ + return await integration.get_courses(user) + + +@router.post("/course_info") +async def get_user_info( + user: schema.User, + course_id: int = 2745, +) -> list[schema.ExtendedCourse]: + """ + Get LMS course info for current user. + """ + return await integration.get_extended_course(user, course_id) + + +@router.post("/events") +async def get_events( + user: schema.User, + body: ModeusTimeBody, +) -> list[schema.ModuleResponse]: + """ + Get LMS events for current user. + """ + return await integration.get_filtered_courses(user, body) diff --git a/backend/yet_another_calendar/web/api/modeus/schema.py b/backend/yet_another_calendar/web/api/modeus/schema.py index 1b8f5f4..0f0d8a4 100644 --- a/backend/yet_another_calendar/web/api/modeus/schema.py +++ b/backend/yet_another_calendar/web/api/modeus/schema.py @@ -13,7 +13,7 @@ async def get_cookies_from_headers() -> str | Response: return await integration.login(settings.modeus_username, settings.modeus_password) -class ModeusCreds(BaseModel): +class Creds(BaseModel): """Modeus creds.""" username: str @@ -21,16 +21,8 @@ class ModeusCreds(BaseModel): class ModeusTimeBody(BaseModel): - time_min: datetime.datetime = Field(alias="timeMin", examples=["2024-09-23T00:00:00+03:00"]) - time_max: datetime.datetime = Field(alias="timeMax", examples=["2024-09-29T23:59:59+03:00"]) - - -# noinspection PyNestedDecorators -class ModeusEventsBody(ModeusTimeBody): - """Modeus search events body.""" - size: int = Field(default=50) - attendee_person_id: list[uuid.UUID] = Field(alias="attendeePersonId", - default=["d69c87c8-aece-4f39-b6a2-7b467b968211"]) + time_min: datetime.datetime = Field(alias="timeMin", examples=["2024-09-23T00:00:00+00:00"]) + time_max: datetime.datetime = Field(alias="timeMax", examples=["2024-09-29T23:59:59+00:00"]) @field_validator("time_min") @classmethod @@ -39,6 +31,8 @@ def validate_time_min(cls, time_min: datetime.datetime) -> datetime.datetime: raise ValueError("Weekday time_min must be Monday.") if time_min.second or time_min.hour or time_min.minute: raise ValueError("Time must me 00:00:00.") + if time_min.tzinfo != datetime.timezone.utc: + raise ValueError("Time must be UTC.") return time_min @field_validator("time_max") @@ -48,8 +42,18 @@ def validate_time_max(cls, time_max: datetime.datetime) -> datetime.datetime: raise ValueError("Weekday time_min must be Sunday.") if time_max.hour != 23 or time_max.second != 59 or time_max.minute != 59: raise ValueError("Time must me 23:59:59.") + if time_max.tzinfo != datetime.timezone.utc: + raise ValueError("Time must be UTC.") return time_max + +# noinspection PyNestedDecorators +class ModeusEventsBody(ModeusTimeBody): + """Modeus search events body.""" + size: int = Field(default=50) + attendee_person_id: list[uuid.UUID] = Field(alias="attendeePersonId", + default=["d69c87c8-aece-4f39-b6a2-7b467b968211"]) + @model_validator(mode='after') def check_passwords_match(self) -> Self: delta = self.time_max - self.time_min @@ -108,14 +112,22 @@ def id(self) -> uuid.UUID: return uuid.UUID(self.href.replace('/', '')) -class Link(BaseModel): +class EventLinks(BaseModel): + course_unit_realization: Href = Field(alias="course-unit-realization") + + +class EventWithLinks(Event): + links: EventLinks = Field(alias="_links") + + +class AttenderLink(BaseModel): self: Href event: Href person: Href class Attender(BaseModel): - links: Link = Field(alias="_links") + links: AttenderLink = Field(alias="_links") class ShortPerson(BaseModel): @@ -123,15 +135,22 @@ class ShortPerson(BaseModel): full_name: str = Field(alias="fullName") +class Course(BaseModel): + id: uuid.UUID + name: str + + class CalendarEmbedded(BaseModel): - events: list[Event] = Field(alias="events") + events: list[EventWithLinks] = Field(alias="events") locations: list[Location] = Field(alias="event-locations") attendees: list[Attender] = Field(alias="event-attendees") people: list[ShortPerson] = Field(alias="persons") + courses: list[Course] = Field(alias="course-unit-realizations") class FullEvent(Event, Location): teacher_full_name: str + course_name: str class ModeusCalendar(BaseModel): @@ -143,20 +162,24 @@ 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.people} + courses = {course.id: course for course in self.embedded.courses} teachers_with_events = {teacher.links.event.id: teacher.links for teacher in self.embedded.attendees} full_events = [] for event in self.embedded.events: + course_id = event.links.course_unit_realization.id try: + course_name = courses[course_id].name teacher_event = teachers_with_events[event.id] teacher = teachers[teacher_event.person.id] teacher_full_name = teacher.full_name except KeyError: + course_name = 'unknown' teacher_full_name = 'unknown' location = locations[event.id] if location.is_lxp: continue full_events.append(FullEvent(**{ - "teacher_full_name": teacher_full_name, + "teacher_full_name": teacher_full_name, "course_name": course_name, **event.model_dump(by_alias=True), **location.model_dump(by_alias=True), })) return full_events diff --git a/backend/yet_another_calendar/web/api/netology/schema.py b/backend/yet_another_calendar/web/api/netology/schema.py index 6f38d55..90c3de5 100644 --- a/backend/yet_another_calendar/web/api/netology/schema.py +++ b/backend/yet_another_calendar/web/api/netology/schema.py @@ -54,6 +54,7 @@ class BaseLesson(BaseModel): lesson_id: int type: str title: str + block_title: str class LessonWebinar(BaseLesson): @@ -126,21 +127,23 @@ class NetologyProgram(BaseModel): class CalendarResponse(BaseModel): lessons: list[NetologyProgram] + block_title: str = Field(alias="title") @staticmethod def filter_lessons( + block_title: str, program: NetologyProgram, time_min: datetime.datetime, time_max: datetime.datetime, ) -> tuple[list[LessonTask], list[LessonWebinar]]: """Filter lessons by time and status.""" homework_events, webinars = [], [] for lesson in program.lesson_items: if lesson['type'] in ["task", "test"]: - homework = LessonTask(**lesson) + homework = LessonTask(**lesson, block_title=block_title) if homework.is_suitable_time(time_min, time_max): homework_events.append(homework) continue if lesson['type'] == "webinar": - webinar = LessonWebinar(**lesson) + webinar = LessonWebinar(**lesson, block_title=block_title) if webinar.is_suitable_time(time_min, time_max): webinars.append(webinar) continue @@ -152,7 +155,7 @@ def get_serialized_lessons(self, body: ModeusTimeBody) -> tuple[list[Any], list[ time_min = body.time_min time_max = body.time_max for lesson in self.lessons: - homework_events, webinars_events = self.filter_lessons(lesson, time_min, time_max) + homework_events, webinars_events = self.filter_lessons(self.block_title, lesson, time_min, time_max) filtered_homework.extend(homework_events) filtered_webinars.extend(webinars_events) return filtered_homework, filtered_webinars diff --git a/backend/yet_another_calendar/web/api/router.py b/backend/yet_another_calendar/web/api/router.py index 94169ff..08f877f 100644 --- a/backend/yet_another_calendar/web/api/router.py +++ b/backend/yet_another_calendar/web/api/router.py @@ -1,7 +1,7 @@ from fastapi.routing import APIRouter from yet_another_calendar.settings import settings -from yet_another_calendar.web.api import docs, monitoring, netology, modeus, bulk +from yet_another_calendar.web.api import docs, monitoring, netology, modeus, bulk, lms api_router = APIRouter() api_router.include_router(monitoring.router) @@ -10,3 +10,4 @@ api_router.include_router(netology.router, prefix="/netology", tags=["netology"]) api_router.include_router(modeus.router, prefix="/modeus", tags=["modeus"]) api_router.include_router(bulk.router, prefix="/bulk", tags=["bulk"]) +api_router.include_router(lms.router, prefix="/lms", tags=["lms"]) diff --git a/docker-compose.yaml b/docker-compose.yaml index 1518d60..6819d88 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,6 +9,7 @@ services: restart: always volumes: - ./backend:/app/src/ + env_file: - backend/.env depends_on: redis: @@ -38,6 +39,7 @@ services: target: dev-run image: ghcr.io/azamatkomaev/yet_another_calendar_frontend:${YET_ANOTHER_CALENDAR_VERSION:-latest} container_name: calendar-frontend + env_file: - frontend/.env restart: always volumes: