Skip to content

Commit

Permalink
Merge branch 'frontend' of github.com:depocoder/YetAnotherCalendar in…
Browse files Browse the repository at this point in the history
…to frontend
  • Loading branch information
AlexandrKarpovich committed Oct 13, 2024
2 parents d930074 + d87df6d commit 00f7808
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 41 deletions.
4 changes: 3 additions & 1 deletion backend/.env.dist
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
MODEUS_USERNAME=test_username
MODEUS_PASSWORD=test_password
MODEUS_PASSWORD=test_password
YET_ANOTHER_CALENDAR_WORKERS_COUNT=10
YET_ANOTHER_CALENDAR_DEBUG=False
5 changes: 0 additions & 5 deletions backend/requirements.txt

This file was deleted.

6 changes: 2 additions & 4 deletions backend/yet_another_calendar/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,8 @@ class Settings(BaseSettings):
host: str = "127.0.0.1"
port: int = 8000
# quantity of workers for uvicorn
workers_count: int = env.int("YET_ANOTHER_WORKERS_COUNT", 1)
# Enable uvicorn reloading
reload: bool = env.bool("YET_ANOTHER_CALENDAR_RELOAD", False)

workers_count: int = 1
# Enable uvicorn reloading, debug and docs
debug: bool = env.bool("YET_ANOTHER_CALENDAR_DEBUG", False)

log_level: LogLevel = LogLevel.INFO
Expand Down
34 changes: 19 additions & 15 deletions backend/yet_another_calendar/web/api/bulk/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,22 @@


def create_ics_event(title: str, starts_at: datetime.datetime, ends_at: datetime.datetime,
lesson_id: Any, timezone: datetime.tzinfo, description: Optional[str] = None,
lesson_id: Any, description: Optional[str] = None,
webinar_url: Optional[str] = None) -> icalendar.Event:
event = icalendar.Event()
dt_now = datetime.datetime.now(tz=timezone)
dt_now = datetime.datetime.now()
event.add('summary', title)
event.add('location', webinar_url)
event.add('dtstart', starts_at.astimezone(timezone))
event.add('dtend', ends_at.astimezone(timezone))
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)
return event


def export_to_ics(calendar: schema.CalendarResponse, timezone: str) -> Iterable[bytes]:
try:
tz = pytz.timezone(timezone)
except pytz.exceptions.UnknownTimeZoneError:
raise HTTPException(detail="Wrong timezone", status_code=status.HTTP_400_BAD_REQUEST) from None
def export_to_ics(calendar: schema.CalendarResponse) -> Iterable[bytes]:
ics_calendar = icalendar.Calendar()
ics_calendar.add('version', '2.0')
ics_calendar.add('prodid', 'yet_another_calendar')
Expand All @@ -50,12 +46,12 @@ def export_to_ics(calendar: schema.CalendarResponse, timezone: str) -> Iterable[
continue
event = create_ics_event(title=f"Netology: {netology_lesson.title}", starts_at=netology_lesson.starts_at,
ends_at=netology_lesson.ends_at, lesson_id=netology_lesson.id,
timezone=tz, webinar_url=netology_lesson.webinar_url)
webinar_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, timezone=tz,
lesson_id=modeus_lesson.id,
description=modeus_lesson.description)
ics_calendar.add_component(event)
yield ics_calendar.to_ical()
Expand All @@ -66,11 +62,12 @@ async def refresh_events(
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)
cached_json = await get_cached_calendar(body, jwt_token, calendar_id, cookies, timezone)
cached_calendar = schema.CalendarResponse.model_validate(cached_json)
calendar = await get_calendar(body, jwt_token, calendar_id, cookies)
calendar = await get_calendar(body, 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={})
Expand All @@ -93,13 +90,19 @@ async def get_calendar(
jwt_token: str,
calendar_id: int,
cookies: netology_schema.NetologyCookies,
timezone: str,
) -> schema.CalendarResponse:
try:
tz = pytz.timezone(timezone)
except pytz.exceptions.UnknownTimeZoneError:
raise HTTPException(detail="Wrong timezone", status_code=status.HTTP_400_BAD_REQUEST) from 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))
return schema.CalendarResponse.model_validate(
{"netology": netology_response.result(), "modeus": modeus_response.result()},
)
).change_timezone(tz)


@cache(expire=settings.redis_events_time_live)
Expand All @@ -108,5 +111,6 @@ async def get_cached_calendar(
jwt_token: str,
calendar_id: int,
cookies: netology_schema.NetologyCookies,
timezone: str,
) -> schema.CalendarResponse:
return await get_calendar(body, jwt_token, calendar_id, cookies)
return await get_calendar(body, jwt_token, calendar_id, cookies, timezone)
13 changes: 13 additions & 0 deletions backend/yet_another_calendar/web/api/bulk/schema.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import hashlib
from typing import Self

from pydantic import BaseModel, Field

Expand All @@ -11,6 +12,18 @@ class BulkResponse(BaseModel):
netology: netology_schema.SerializedEvents
modeus: list[modeus_schema.FullEvent]

def change_timezone(self, timezone: datetime.tzinfo) -> Self:
for homework in self.netology.homework:
if homework.deadline:
homework.deadline = homework.deadline.astimezone(timezone)
for webinar in self.netology.webinars:
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)
return self

class CalendarResponse(BulkResponse):
cached_at: datetime.datetime = Field(default_factory=datetime.datetime.now, alias="cached_at")
Expand Down
10 changes: 6 additions & 4 deletions backend/yet_another_calendar/web/api/bulk/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ async def get_calendar(
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,
time_zone: str = "Europe/Moscow",
) -> schema.CalendarResponse:
"""
Get events from Netology and Modeus, cached.
"""

cached_calendar = await integration.get_cached_calendar(body, jwt_token, calendar_id, cookies)
cached_calendar = await integration.get_cached_calendar(body, jwt_token, calendar_id, cookies, time_zone)
return schema.CalendarResponse.model_validate(cached_calendar)


Expand All @@ -36,12 +37,13 @@ async def refresh_calendar(
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,
time_zone: str = "Europe/Moscow",
) -> schema.RefreshedCalendarResponse:
"""
Refresh events in redis.
"""

return await integration.refresh_events(body, jwt_token, calendar_id, cookies)
return await integration.refresh_events(body, jwt_token, calendar_id, cookies, time_zone)


@router.post("/export_ics/")
Expand All @@ -55,5 +57,5 @@ async def export_ics(
"""
Export into .ics format
"""
calendar = await integration.get_calendar(body, jwt_token, calendar_id, cookies)
return StreamingResponse(integration.export_to_ics(calendar, time_zone))
calendar = await integration.get_calendar(body, jwt_token, calendar_id, cookies, time_zone)
return StreamingResponse(integration.export_to_ics(calendar))
28 changes: 28 additions & 0 deletions backend/yet_another_calendar/web/api/modeus/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ class ModeusCreds(BaseModel):
username: str
password: str = Field(repr=False)


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."""
Expand Down Expand Up @@ -84,6 +86,18 @@ class Event(BaseModel):
end_time: datetime.datetime = Field(alias="end")
id: uuid.UUID

@field_validator("start_time")
@classmethod
def validate_starts_at(cls, start_time: datetime.datetime,
timezone: datetime.tzinfo = datetime.timezone.utc) -> datetime.datetime:
return start_time.astimezone(timezone)

@field_validator("end_time")
@classmethod
def validate_end_time(cls, end_time: datetime.datetime,
timezone: datetime.tzinfo = datetime.timezone.utc) -> datetime.datetime:
return end_time.astimezone(timezone)


class Href(BaseModel):
href: str
Expand Down Expand Up @@ -157,6 +171,20 @@ class StudentsSpeciality(BaseModel):
specialty_name: Optional[str] = Field(alias="specialtyName")
specialty_profile: Optional[str] = Field(alias="specialtyProfile")

@field_validator("learning_start_date")
@classmethod
def validate_starts_at(cls, learning_start_date: Optional[datetime.datetime]) -> Optional[datetime.datetime]:
if not learning_start_date:
return learning_start_date
return learning_start_date.astimezone(datetime.timezone.utc)

@field_validator("learning_end_date")
@classmethod
def validate_learning_end_date(cls, learning_end_date: Optional[datetime.datetime]) -> Optional[datetime.datetime]:
if not learning_end_date:
return learning_end_date
return learning_end_date.astimezone(datetime.timezone.utc)


class ExtendedPerson(StudentsSpeciality, ShortPerson):
pass
Expand Down
47 changes: 40 additions & 7 deletions backend/yet_another_calendar/web/api/netology/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from urllib.parse import urljoin

from fastapi import Header
from pydantic import BaseModel, Field, computed_field
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator, ConfigDict

from yet_another_calendar.settings import settings
from yet_another_calendar.web.api.modeus.schema import ModeusTimeBody
Expand Down Expand Up @@ -64,6 +64,22 @@ class LessonWebinar(BaseLesson):
video_url: Optional[str] = None
webinar_url: Optional[str] = None

@field_validator("starts_at")
@classmethod
def validate_starts_at(cls, starts_at: Optional[datetime.datetime],
timezone: datetime.tzinfo = datetime.timezone.utc) -> Optional[datetime.datetime]:
if not starts_at:
return starts_at
return starts_at.astimezone(timezone)

@field_validator("ends_at")
@classmethod
def validate_ends_at(cls, ends_at: Optional[datetime.datetime],
timezone: datetime.tzinfo = datetime.timezone.utc) -> Optional[datetime.datetime]:
if not ends_at:
return ends_at
return ends_at.astimezone(timezone)

def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datetime) -> bool:
"""Check if lesson have suitable time"""
if not self.starts_at or time_min > self.starts_at:
Expand All @@ -75,21 +91,27 @@ def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datet

# noinspection PyNestedDecorators
class LessonTask(BaseLesson):
model_config = ConfigDict(arbitrary_types_allowed=True)

path: str
deadline: Optional[datetime.datetime] = Field(default=None)

@computed_field # type: ignore
@property
def url(self) -> str:
return urljoin(settings.netology_url, self.path)

@computed_field # type: ignore
@property
def deadline(self) -> Optional[datetime.datetime]:
match = re.search(_DATE_PATTERN, self.title)
@model_validator(mode='before')
@classmethod
def deadline_validation(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
match = re.search(_DATE_PATTERN, data.get('title', ''))
if not match:
return None
return data
date = match.group(0)
return datetime.datetime.strptime(date, "%d.%m.%y").replace(tzinfo=datetime.timezone.utc)
data['deadline'] = datetime.datetime.strptime(date, "%d.%m.%y").astimezone(datetime.timezone.utc)
return data

def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datetime) -> bool:
"""Check if lesson have suitable time"""
Expand Down Expand Up @@ -158,6 +180,16 @@ class DetailedProgram(BaseModel):
start_date: datetime.datetime
finish_date: datetime.datetime

@field_validator("start_date")
@classmethod
def validate_start_date(cls, start_date: datetime.datetime) -> datetime.datetime:
return start_date.astimezone(datetime.timezone.utc)

@field_validator("finish_date")
@classmethod
def validate_finish_date(cls, finish_date: datetime.datetime) -> datetime.datetime:
return finish_date.astimezone(datetime.timezone.utc)


class Program(BaseModel):
detailed_program: DetailedProgram = Field(alias='program')
Expand All @@ -173,6 +205,7 @@ def get_lesson_ids(self) -> set[int]:
program_ids.add(program.detailed_program.id)
return program_ids


class SerializedEvents(BaseModel):
"""Structure for displaying frontend."""
homework: list[LessonTask]
Expand Down
49 changes: 49 additions & 0 deletions docker-compose.prod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
services:
api: &main_app
ports:
- "127.0.0.1:8000:8000"
container_name: yet_another_calendar-api
image: ghcr.io/azamatkomaev/yet_another_calendar_backend:${YET_ANOTHER_CALENDAR_VERSION:-latest}
restart: always
volumes:
- ./backend:/app/src/
env_file:
- backend/.env
depends_on:
redis:
condition: service_healthy
environment:
YET_ANOTHER_CALENDAR_HOST: 0.0.0.0
YET_ANOTHER_CALENDAR_REDIS_HOST: yet_another_calendar-redis
LOGURU_DIAGNOSE: "False"

redis:
image: redis:latest
hostname: "yet_another_calendar-redis"
restart: always
volumes:
- redis_data:/data
environment:
ALLOW_EMPTY_PASSWORD: "yes"
healthcheck:
test: redis-cli ping
interval: 1s
timeout: 3s
retries: 50

frontend:
image: ghcr.io/azamatkomaev/yet_another_calendar_frontend:${YET_ANOTHER_CALENDAR_VERSION:-latest}
build:
context: ./frontend
target: prod-run
container_name: yet_another_calendar-frontend
env_file:
- frontend/.env
restart: always
volumes:
- ./frontend/src:/app/src
ports:
- "127.0.0.1:3000:3000"

volumes:
redis_data:
Loading

0 comments on commit 00f7808

Please sign in to comment.