diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 543ff54..9ec6993 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -26,14 +26,14 @@ jobs:
password: ${{ env.REGISTRY_PASSWORD }}
- name: Build & Publish backend to Github Container registry
run: |
- docker build ./backend --tag $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-backend:latest \
- --tag $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-backend:$IMAGE_TAG
- docker push $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-backend:latest
- docker push $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-backend:$IMAGE_TAG
+ docker build ./backend --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:latest \
+ --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:$IMAGE_TAG
+ docker push $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:latest
+ docker push $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_backend:$IMAGE_TAG
- name: Build & Publish frontend to Github Container registry
run: |
- docker build ./frontend --tag $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-frontend:latest \
- --tag $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-frontend:$IMAGE_TAG
- docker push $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-frontend:latest
- docker push $REGISTRY_URL/$REGISTRY_USERNAME/calendarit-frontend:$IMAGE_TAG
\ No newline at end of file
+ docker build ./frontend --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:latest \
+ --tag $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:$IMAGE_TAG
+ docker push $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:latest
+ docker push $REGISTRY_URL/$REGISTRY_USERNAME/yet_another_calendar_frontend:$IMAGE_TAG
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 449688b..388c996 100644
--- a/.gitignore
+++ b/.gitignore
@@ -124,7 +124,7 @@ celerybeat.pid
*.sage.py
# Environments
-.env
+frontend/.env
.venv
env/
venv/
diff --git a/backend/.env.dist b/backend/.env.dist
index 4c31759..78a6ac2 100644
--- a/backend/.env.dist
+++ b/backend/.env.dist
@@ -1,2 +1,4 @@
MODEUS_USERNAME=test_username
-MODEUS_PASSWORD=test_password
\ No newline at end of file
+MODEUS_PASSWORD=test_password
+YET_ANOTHER_CALENDAR_WORKERS_COUNT=10
+YET_ANOTHER_CALENDAR_DEBUG=False
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
deleted file mode 100644
index 1e98b42..0000000
--- a/backend/requirements.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-blacksheep[full]~=2.0.0
-uvicorn==0.22.0
-pydantic-settings
-MarkupSafe==2.1.3
-pydantic
diff --git a/backend/yet_another_calendar/settings.py b/backend/yet_another_calendar/settings.py
index fb2fd35..01e0532 100644
--- a/backend/yet_another_calendar/settings.py
+++ b/backend/yet_another_calendar/settings.py
@@ -35,9 +35,7 @@ class Settings(BaseSettings):
port: int = 8000
# quantity of workers for uvicorn
workers_count: int = 1
- # Enable uvicorn reloading
- reload: bool = env.bool("YET_ANOTHER_CALENDAR_RELOAD", False)
-
+ # Enable uvicorn reloading, debug and docs
debug: bool = env.bool("YET_ANOTHER_CALENDAR_DEBUG", False)
log_level: LogLevel = LogLevel.INFO
diff --git a/backend/yet_another_calendar/web/api/bulk/integration.py b/backend/yet_another_calendar/web/api/bulk/integration.py
index 6261a22..e35d8fd 100644
--- a/backend/yet_another_calendar/web/api/bulk/integration.py
+++ b/backend/yet_another_calendar/web/api/bulk/integration.py
@@ -21,14 +21,14 @@
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:
@@ -36,11 +36,7 @@ def create_ics_event(title: str, starts_at: datetime.datetime, ends_at: datetime
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')
@@ -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()
@@ -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={})
@@ -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)
@@ -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)
diff --git a/backend/yet_another_calendar/web/api/bulk/schema.py b/backend/yet_another_calendar/web/api/bulk/schema.py
index 93c31bd..3f16f32 100644
--- a/backend/yet_another_calendar/web/api/bulk/schema.py
+++ b/backend/yet_another_calendar/web/api/bulk/schema.py
@@ -1,5 +1,6 @@
import datetime
import hashlib
+from typing import Self
from pydantic import BaseModel, Field
@@ -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")
diff --git a/backend/yet_another_calendar/web/api/bulk/views.py b/backend/yet_another_calendar/web/api/bulk/views.py
index c7507ac..035d0fb 100644
--- a/backend/yet_another_calendar/web/api/bulk/views.py
+++ b/backend/yet_another_calendar/web/api/bulk/views.py
@@ -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)
@@ -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/")
@@ -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))
diff --git a/backend/yet_another_calendar/web/api/docs/views.py b/backend/yet_another_calendar/web/api/docs/views.py
index d75854f..33942ce 100644
--- a/backend/yet_another_calendar/web/api/docs/views.py
+++ b/backend/yet_another_calendar/web/api/docs/views.py
@@ -22,8 +22,8 @@ async def swagger_ui_html(request: Request) -> HTMLResponse:
openapi_url=request.app.openapi_url,
title=f"{title} - Swagger UI",
oauth2_redirect_url=str(request.url_for("swagger_ui_redirect")),
- swagger_js_url="/static/docs/swagger-ui-bundle.js",
- swagger_css_url="/static/docs/swagger-ui.css",
+ swagger_js_url="/static_backend/docs/swagger-ui-bundle.js",
+ swagger_css_url="/static_backend/docs/swagger-ui.css",
)
@@ -49,5 +49,5 @@ async def redoc_html(request: Request) -> HTMLResponse:
return get_redoc_html(
openapi_url=request.app.openapi_url,
title=f"{title} - ReDoc",
- redoc_js_url="/static/docs/redoc.standalone.js",
+ redoc_js_url="/static_backend/docs/redoc.standalone.js",
)
diff --git a/backend/yet_another_calendar/web/api/modeus/schema.py b/backend/yet_another_calendar/web/api/modeus/schema.py
index d7b67f3..1b8f5f4 100644
--- a/backend/yet_another_calendar/web/api/modeus/schema.py
+++ b/backend/yet_another_calendar/web/api/modeus/schema.py
@@ -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."""
@@ -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
@@ -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
diff --git a/backend/yet_another_calendar/web/api/netology/schema.py b/backend/yet_another_calendar/web/api/netology/schema.py
index 5539371..6f38d55 100644
--- a/backend/yet_another_calendar/web/api/netology/schema.py
+++ b/backend/yet_another_calendar/web/api/netology/schema.py
@@ -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
@@ -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:
@@ -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"""
@@ -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')
@@ -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]
diff --git a/backend/yet_another_calendar/web/api/router.py b/backend/yet_another_calendar/web/api/router.py
index ec2dc19..94169ff 100644
--- a/backend/yet_another_calendar/web/api/router.py
+++ b/backend/yet_another_calendar/web/api/router.py
@@ -1,10 +1,12 @@
from fastapi.routing import APIRouter
+from yet_another_calendar.settings import settings
from yet_another_calendar.web.api import docs, monitoring, netology, modeus, bulk
api_router = APIRouter()
api_router.include_router(monitoring.router)
-api_router.include_router(docs.router)
+if settings.debug:
+ api_router.include_router(docs.router)
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"])
diff --git a/backend/yet_another_calendar/web/application.py b/backend/yet_another_calendar/web/application.py
index 589f486..711aa39 100644
--- a/backend/yet_another_calendar/web/application.py
+++ b/backend/yet_another_calendar/web/application.py
@@ -86,6 +86,6 @@ def get_app() -> FastAPI:
app.include_router(router=api_router, prefix="/api")
# Adds static directory.
# This directory is used to access swagger files.
- app.mount("/static", StaticFiles(directory=APP_ROOT / "static"), name="static")
+ app.mount("/static_backend", StaticFiles(directory=APP_ROOT / "static"), name="static")
return app
diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml
new file mode 100644
index 0000000..ec5b80b
--- /dev/null
+++ b/docker-compose.prod.yaml
@@ -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:
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 9350fcb..b989878 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -5,7 +5,7 @@ services:
build:
context: backend/
dockerfile: Dockerfile
- image: yet_another_calendar:${YET_ANOTHER_CALENDAR_VERSION:-latest}
+ image: ghcr.io/azamatkomaev/yet_another_calendar_backend:${YET_ANOTHER_CALENDAR_VERSION:-dev}
restart: always
volumes:
- ./backend:/app/src/
@@ -17,7 +17,7 @@ services:
environment:
YET_ANOTHER_CALENDAR_HOST: 0.0.0.0
YET_ANOTHER_CALENDAR_REDIS_HOST: yet_another_calendar-redis
- LOGURU_DIAGNOSE: "False"
+ YET_ANOTHER_CALENDAR_DEBUG: True
redis:
image: redis:latest
@@ -37,12 +37,15 @@ services:
build:
context: ./frontend
target: dev-run
+ image: ghcr.io/azamatkomaev/yet_another_calendar_frontend:${YET_ANOTHER_CALENDAR_VERSION:-dev}
container_name: calendar-frontend
+ env_file:
+ - frontend/.env
restart: always
volumes:
- ./frontend/src:/app/src
ports:
- - "3000:3000"
+ - "3000:80"
volumes:
redis_data:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 224de35..0ef13d3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -21,6 +21,10 @@
"react-router-dom": "^6.26.2",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
+ },
+ "devDependencies": {
+ "sass": "^1.79.4",
+ "scss-reset": "^1.4.2"
}
},
"node_modules/@adobe/css-tools": {
@@ -9579,6 +9583,13 @@
"url": "https://opencollective.com/immer"
}
},
+ "node_modules/immutable": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
+ "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -15810,6 +15821,24 @@
"resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz",
"integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA=="
},
+ "node_modules/sass": {
+ "version": "1.79.4",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.4.tgz",
+ "integrity": "sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/sass-loader": {
"version": "12.6.0",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz",
@@ -15847,6 +15876,36 @@
}
}
},
+ "node_modules/sass/node_modules/chokidar": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
+ "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/sass/node_modules/readdirp": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
+ "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
@@ -15920,6 +15979,13 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
},
+ "node_modules/scss-reset": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/scss-reset/-/scss-reset-1.4.2.tgz",
+ "integrity": "sha512-eXtSeI5APjD/TtaIlRdiMRapgsX5GCP4I1Ti3FiUzCSE4GEYnfT1hGISrJkKGZsZbCDhwZv1bUdOOZfPGs3R1A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index a0f8521..5af75bd 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -40,5 +40,9 @@
"last 1 firefox version",
"last 1 safari version"
]
+ },
+ "devDependencies": {
+ "sass": "^1.79.4",
+ "scss-reset": "^1.4.2"
}
}
diff --git a/frontend/src/App.css b/frontend/src/App.css
deleted file mode 100644
index 74b5e05..0000000
--- a/frontend/src/App.css
+++ /dev/null
@@ -1,38 +0,0 @@
-.App {
- text-align: center;
-}
-
-.App-logo {
- height: 40vmin;
- pointer-events: none;
-}
-
-@media (prefers-reduced-motion: no-preference) {
- .App-logo {
- animation: App-logo-spin infinite 20s linear;
- }
-}
-
-.App-header {
- background-color: #282c34;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- font-size: calc(10px + 2vmin);
- color: white;
-}
-
-.App-link {
- color: #61dafb;
-}
-
-@keyframes App-logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
diff --git a/frontend/src/App.js b/frontend/src/App.js
deleted file mode 100644
index 1fbb552..0000000
--- a/frontend/src/App.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import DatePicker from "./components/DataPicker";
-
-function App() {
- return (
-
-
-
- );
-}
-
-export default App;
diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js
deleted file mode 100644
index 1f03afe..0000000
--- a/frontend/src/App.test.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import App from './App';
-
-test('renders learn react link', () => {
- render();
- const linkElement = screen.getByText(/learn react/i);
- expect(linkElement).toBeInTheDocument();
-});
diff --git a/frontend/src/components/Calendar.jsx b/frontend/src/components/Calendar.jsx
deleted file mode 100644
index 23766e3..0000000
--- a/frontend/src/components/Calendar.jsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import flatpickr from "flatpickr";
-import { useCallback, useRef } from "react";
-
-function Calendar() {
- const fp1 = useRef();
-
- const inputRef = useCallback((node) => {
- if (node !== null) {
- fp1.current = flatpickr(node, {
- enableTime: true,
- dateFormat: "Y-m-d H:i",
- });
- }
- }, []);
-
- return ();
-}
-
-export default Calendar;
diff --git a/frontend/src/components/Calendar/Calendar.jsx b/frontend/src/components/Calendar/Calendar.jsx
index a2c9be1..f5934ee 100644
--- a/frontend/src/components/Calendar/Calendar.jsx
+++ b/frontend/src/components/Calendar/Calendar.jsx
@@ -8,7 +8,7 @@ const Calendar = () => {
if (node !== null) {
fp1.current = flatpickr(node, {
enableTime: true,
- dateFormat: "Y-m-d H:i",
+ dateFormat: "j, F",
});
}
}, []);
diff --git a/frontend/src/components/Calendar/left-week.png b/frontend/src/components/Calendar/left-week.png
new file mode 100644
index 0000000..58b6e88
Binary files /dev/null and b/frontend/src/components/Calendar/left-week.png differ
diff --git a/frontend/src/components/Calendar/right-week.png b/frontend/src/components/Calendar/right-week.png
new file mode 100644
index 0000000..0ae2959
Binary files /dev/null and b/frontend/src/components/Calendar/right-week.png differ
diff --git a/frontend/src/components/DataPicker.jsx b/frontend/src/components/DataPicker.jsx
deleted file mode 100644
index 95b68bc..0000000
--- a/frontend/src/components/DataPicker.jsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React, { useRef, useEffect } from 'react';
-import Flatpickr from 'flatpickr';
-import weekSelect from 'flatpickr';
-import 'flatpickr/dist/flatpickr.css';
-
-const DatePicker = ({ value, onChange, options }) => {
- const datePickerRef = useRef(null);
-
-
- useEffect(() => {
- if (datePickerRef.current) {
- const fp = new Flatpickr(datePickerRef.current, {
-
- });
- }
- }, []);
-
- return (
-
-
-
- );
-};
-
-export default DatePicker;
diff --git a/frontend/src/components/Header/Header.js b/frontend/src/components/Header/Header.js
index ce488fe..1ce3a7f 100644
--- a/frontend/src/components/Header/Header.js
+++ b/frontend/src/components/Header/Header.js
@@ -1,103 +1,148 @@
import React from "react";
import { useNavigate } from "react-router-dom";
+import cross from "./cross.png";
+import arrow from "./arrow.png"
+import camera from "./camera.png"
+
export default function Header() {
const navigate = useNavigate();
return (
-
+
-
-
Мое расписание
+
+ Мое расписание
+
-
-
+
+
+
Дедлайн Нетология
23.09.2024
+
+
+ Программирование на Python
+
+
+ Домашнее задание с самопроверкой(дедлайн 12.12.24)
+
+
+
+
|
- Пн |
- Вт |
- Ср |
- Чт |
- Пт |
- Сб |
- Вс |
+ Пн 23.09 |
+ Вт 24.09 |
+ Ср 25.09 |
+ Чт 26.09 |
+ Пт 27.09 |
+ Сб 28.09 |
+ Вс 29.09 |
-
- дедлайны
- Скрыть
- |
- 1 |
- 2 |
- 3 |
- 4 |
- 5 |
- 6 |
- 7 |
+
+ дедлайны
+ Скрыть
+ |
+
+ ТюмГУ
+ Нетология
+ |
+ |
+ ТюмГУ |
+ |
+ Нетология |
+ |
+ |
- 2 пара 10:15 11:45 |
- 1 |
- 2 |
- 3 |
- 4 |
- 5 |
- 6 |
- 7 |
+ 2 пара 10:15 11:45 |
+ |
+ |
+ |
+ |
+ |
+
+ ТюмГУ
+ Математический анализ |
+ |
- 3 пара 12:00 13:30 |
- 1 |
- 2 |
- 3 |
- 4 |
- 5 |
- 6 |
- 7 |
+ 3 пара 12:00 13:30 |
+ |
+ |
+ |
+ |
+ |
+
+ ТюмГУ
+ Математический анализ |
+ |
- 4 пара 14:00 15:30 |
- 1 |
- 2 |
- 3 |
- 4 |
- 5 |
- 6 |
- 7 |
+ 4 пара 14:00 15:30 |
+ |
+ |
+ |
+ |
+
+ ТюмГУ
+ Философия |
+ |
+ |
- 5 пара 15:45 17:15 |
- 1 |
- 2 |
- 3 |
- 4 |
- 5 |
- 6 |
- 7 |
+ 5 пара 15:45 17:15 |
+
+ ТюмГУ
+ Иностранный язык |
+ |
+
+ ТюмГУ
+ История Росссии |
+ |
+
+ ТюмГУ
+ Философия |
+ |
+ |
- 6 пара 17:30 19:00 |
- 1 |
- 2 |
- 3 |
- 4 |
- 5 |
- 6 |
- 7 |
+ 6 пара 17:30 19:00 |
+
+ ТюмГУ
+ Иностранный язык |
+ |
+
+ ТюмГУ
+ История Росссии |
+
+ ТюмГУ
+ Алгебра |
+ |
+ |
+ |
- 7 пара 19:10 20:40 |
- 1 |
- 2 |
- 3 |
- 4 |
- 5 |
- 6 |
- 7 |
+ 7 пара 19:10 20:40 |
+ |
+
+ Нетология
+ Введение в специальность |
+
+ Нетология
+ Программирование на Python |
+
+ ТюмГУ
+ Алгебра |
+ |
+ |
+ |
diff --git a/frontend/src/components/Header/Shape-reverse.png b/frontend/src/components/Header/Shape-reverse.png
new file mode 100644
index 0000000..0ae2959
Binary files /dev/null and b/frontend/src/components/Header/Shape-reverse.png differ
diff --git a/frontend/src/components/Header/arrow.png b/frontend/src/components/Header/arrow.png
new file mode 100644
index 0000000..66e4e16
Binary files /dev/null and b/frontend/src/components/Header/arrow.png differ
diff --git a/frontend/src/components/Header/camera.png b/frontend/src/components/Header/camera.png
new file mode 100644
index 0000000..a691948
Binary files /dev/null and b/frontend/src/components/Header/camera.png differ
diff --git a/frontend/src/components/Header/cross.png b/frontend/src/components/Header/cross.png
new file mode 100644
index 0000000..39835f3
Binary files /dev/null and b/frontend/src/components/Header/cross.png differ
diff --git a/frontend/src/components/Header/left-week.png b/frontend/src/components/Header/left-week.png
new file mode 100644
index 0000000..58b6e88
Binary files /dev/null and b/frontend/src/components/Header/left-week.png differ
diff --git a/frontend/src/components/Login/ModeusLoginForm.jsx b/frontend/src/components/Login/ModeusLoginForm.jsx
deleted file mode 100644
index 29160c6..0000000
--- a/frontend/src/components/Login/ModeusLoginForm.jsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { useState } from "react";
-import { loginModeus, searchModeus} from "../../services/api/login";
-import { useNavigate } from "react-router-dom"; // Импортируем useNavigate для навигации
-
-const ModeusLoginForm = () => {
- const [email, setEmail] = useState(null);
- const [password, setPassword] = useState(null);
- const [fullName, setFullName] = useState(""); // Строка для поиска
- const [searchResults, setSearchResults] = useState([]); // Результаты поиска
- // const [selectedName, setSelectedName] = useState(""); // Выбранное имя
- const [showSuggestions, setShowSuggestions] = useState(false); // Флаг показа списка
- const [errorMessage, setErrorMessage] = useState(""); // Сообщение об ошибке
-
- const navigate = useNavigate(); // Инициализируем хук для навигации
-
- const onClickSearch = async (fullName) => {
- console.log("Поиск ФИО:", fullName);
-
- let response = await searchModeus(fullName);
- if (response.status !== 200) {
- setErrorMessage("Неверное ФИО. Попробуйте еще раз.");
- return;
- }
- console.log("Результаты поиска:", response.data);
- setSearchResults(response.data);
- setShowSuggestions(true); // Показываем список после поиска
- setErrorMessage(""); // Очищаем ошибку при успешном поиске
- };
-
- /// Обработчик нажатия клавиши "Enter"
- const handleKeyPress = (e) => {
- if (e.key === "Enter") {
- onClickSearch(fullName); // Выполнить поиск, если нажата клавиша Enter
- }
- };
-
- // Обработчик выбора варианта из списка
- const handleSelect = (name) => {
- setFullName(name); // Устанавливаем выбранное имя
- setShowSuggestions(false); // Скрываем список после выбора
- };
-
- const onClickLogin = async () => {
- let response = await loginModeus(email, password);
-
- if (response.status !== 200) {
- setErrorMessage("Неверный логин или пароль. Попробуйте еще раз."); // Устанавливаем текст ошибки
- return;
- }
- console.log(response)
- localStorage.setItem("token", response.data?.token);
- setErrorMessage(""); // Очищаем ошибку при успешном логине
-
- // Перенаправление на страницу календаря
- navigate("/calendar");
- window.location.reload(); // Обновляем страницу после навигации
- };
-
- return (
-
-
Мое расписание
-
-
-
-
-
-
setFullName(e.target.value)} // Обновляем строку поиска
- onKeyPress={handleKeyPress} // Обработчик для нажатия клавиш
- />
-
- {/* Рендерим выпадающий список или сообщение об отсутствии результатов */}
- {showSuggestions && (
-
- {searchResults.length > 0 ? (
- searchResults.map((person, index) => (
- - handleSelect(person.fullName)}>
- {person.fullName} {/* Отображаем имя */}
-
- ))
- ) : (
- - Нет такого имени
// Сообщение, если список пуст
- )}
-
- )}
-
-
-
-
-
-
-
- setEmail(e.target.value)}
- />
- setPassword(e.target.value)}
- />
-
- {/* Сообщение об ошибке */}
- {errorMessage &&
{errorMessage}
}
-
-
- Войти
-
-
-
- );
-};
-
-export default ModeusLoginForm;
diff --git a/frontend/src/context/AuthContext.js b/frontend/src/context/AuthContext.js
new file mode 100644
index 0000000..b2d980e
--- /dev/null
+++ b/frontend/src/context/AuthContext.js
@@ -0,0 +1,19 @@
+// AuthContext.js
+import React, { createContext, useState } from 'react';
+
+// Создаем контекст
+export const AuthContext = createContext();
+
+// Создаем провайдер
+export const AuthProvider = ({ children }) => {
+ const [authData, setAuthData] = useState({
+ email: null,
+ password: null,
+ });
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/index.css b/frontend/src/index.css
deleted file mode 100644
index e30857f..0000000
--- a/frontend/src/index.css
+++ /dev/null
@@ -1,218 +0,0 @@
-@import url("https://fonts.googleapis.com/css2?family=Unbounded:wght@200..900&display=swap");
-@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Unbounded:wght@200..900&display=swap");
-@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap");
-
-html {
- height: 100%;
-}
-
-body {
- height: 100%;
- background: #fff;
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
- "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
- "Helvetica Neue", sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
- monospace;
-}
-
-.wrapper {
- width: 100%;
- -webkit-touch-callout: none;
- -webkit-user-select: none;
- -khtml-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
- margin-top: 20px;
-}
-
-.header-line {
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.raspisanie-export {
- display: flex;
- align-items: center;
-}
-
-header .raspisanie {
- margin-left: 120px;
- font-family: "Unbounded", sans-serif;
- font-weight: 500;
- font-size: 32px;
-}
-header .export-btn {
- cursor: pointer;
- width: 90px;
- height: 28px;
- background-color: #5856d6;
- color: #fff;
- text-decoration: none;
- border: none;
- border-radius: 6px;
- padding: 4px 8px;
- font-family: "Roboto", sans-serif;
- font-weight: 400;
- font-style: normal;
- margin-left: 18px;
- transition: color 0.2s linear;
-}
-
-header .export-btn:hover {
- background-color: #4745b5;
-}
-
-header .login-btn {
- margin-right: 120px;
- cursor: pointer;
- border: none;
- background-color: #5856d6;
- color: #fff;
- text-decoration: none;
- border-radius: 6px;
- padding: 10px 20px;
- font-family: "Roboto", sans-serif;
- font-weight: 400;
- transition: color 0.2s linear;
-}
-
-header .login-btn:hover {
- background-color: #4745b5;
-}
-
-.input-name {
- margin-top: 8px;
- width: 320px;
- height: 32px;
- border-radius: 4px;
- border-color: #d9d9d9;
-}
-
-.input-email {
- margin-top: 8px;
- width: 320px;
- height: 32px;
- border-radius: 4px;
- border-color: #d9d9d9;
-}
-
-.login-btn-log {
- width: 100px;
- position: relative;
- cursor: pointer;
- border: none;
- margin: 6px auto;
- background-color: #5856d6;
- color: #fff;
- text-decoration: none;
- border-radius: 6px;
- padding: 10px 20px;
- font-family: "Roboto", sans-serif;
- font-weight: 400;
- transition: color 0.2s linear;
-}
-.login-btn-log:hover {
- background-color: #4745b5;
-}
-
-.login-container {
- display: flex;
- align-items: center;
- flex-direction: column;
-}
-
-.login-netologiya, .login-fio {
- max-width: 320px;
- width: 100%;
- margin: 24px auto;
- display: flex;
- align-items: flex-start;
- justify-content: center;
- flex-direction: column;
- text-align: left;
-}
-
-.calendar {
- position: absolute;
- top: 165px;
- right: 248px;
- background-color: #ecedf0;
- width: 406px;
- height: 32px;
- border-radius: 6px;
- border: 1px solid rgba(217, 217, 217, 0.5);
- font-family: "Unbounded", sans-serif;
- font-weight: 500;
- font-size: 15px;
- padding-left: 14px;
- transition: color 0.2s linear;
-}
-.calendar:hover {
- background-color: #e7e7ea;
-}
-
-.raspisanie-table {
- width: 100%;
- margin-top: 170px;
- border-spacing: 1px;
-}
-.days {
- padding-left: 30px;
- font-family: "Unbounded", sans-serif;
- font-weight: 500;
- font-size: 13px;
- border: 1px;
- border-bottom: 1px dotted #adadad;
-}
-.vertical {
- text-align: center;
- border-bottom: 1px dotted #adadad;
-}
-.vertical-zagolovok {
- padding-left: 47px;
- padding-top: 7px;
- padding-bottom: 7px;
- font-family: "Roboto Mono", monospace;
- font-weight: 400;
- font-size: 12px;
- width: 0;
- border-bottom: 1px dotted #adadad;
-}
-
-.vertical-zagolovok::first-line {
- font-family: "Roboto", sans-serif;
- font-weight: 700;
- font-size: 13px;
-}
-
-.off-deadline {
- margin-top: 7px;
- font-family: "Roboto", sans-serif;
- font-weight: 400;
- font-size: 13px;
- color: #7b61ff;
- background: none;
- padding: 4px 8px;
- border-radius: 6px;
- border: 1px solid #7b61ff;
- transition: color 0.3s linear;
-}
-.off-deadline:hover {
- cursor: pointer;
- background-color: #5856d6;
- color: #fff;
-}
-
-.error-message {
- color: red;
- margin-top: 10px;
-}
diff --git a/frontend/src/index.js b/frontend/src/index.js
index 05ae907..12f3815 100644
--- a/frontend/src/index.js
+++ b/frontend/src/index.js
@@ -3,10 +3,12 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
import reportWebVitals from "./reportWebVitals";
import ReactDOM from "react-dom/client";
+import Header from "./components/Header/Header";
import LoginRoute from "./pages/LoginRoute";
import CalendarRoute from "./pages/CalendarRoute";
+import { AuthProvider } from './context/AuthContext';
-import "./index.css";
+import "./index.scss";
import PrivateRoute from "./components/Calendar/PrivateRoute";
const checkAuth = () => {
@@ -29,16 +31,18 @@ const router = createBrowserRouter([
{
path: "/calendar",
element: (
-
-
-
+
+
+
), // Защищаем страницу календаря
},
]);
root.render(
-
+ {/* Оборачиваем в AuthProvider для контекста */}
+
+
);
diff --git a/frontend/src/index.scss b/frontend/src/index.scss
new file mode 100644
index 0000000..372af5c
--- /dev/null
+++ b/frontend/src/index.scss
@@ -0,0 +1,531 @@
+@import url("https://fonts.googleapis.com/css2?family=Unbounded:wght@200..900&display=swap");
+@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Unbounded:wght@200..900&display=swap");
+@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap");
+
+// app file start ========
+.App {
+ text-align: center;
+ &-logo {
+ height: 40vmin;
+ pointer-events: none;
+ }
+ &-header {
+ background-color: #282c34;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: calc(10px + 2vmin);
+ color: white;
+ }
+ &-link {
+ color: #61dafb;
+ }
+}
+//TODO: ругается здесь
+//media (prefers-reduced-motion: no-preference) {
+// animation: App-logo-spin infinite 20s linear;
+//}
+//keyframes App-logo-spin {
+// transform: rotate(0deg);
+//}
+//to {
+// transform: rotate(360deg);
+//}
+// app file end ========
+
+
+
+html {
+ height: 100%;
+}
+
+body {
+ height: 100%;
+ background: #fff;
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
+ "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
+ "Helvetica Neue", sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
+ monospace;
+}
+
+.wrapper {
+ width: 100%;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ margin-top: 20px;
+}
+
+.header-line {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.shedule-export {
+ display: flex;
+ align-items: center;
+}
+
+header .shedule {
+ margin-left: 120px;
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 32px;
+}
+
+header .export-btn {
+ cursor: pointer;
+ height: 28px;
+ background-color: #5856d6;
+ color: #fff;
+ text-decoration: none;
+ border: none;
+ border-radius: 6px;
+ padding: 4px 8px;
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ font-style: normal;
+ margin-left: 18px;
+ transition: color 0.2s linear;
+ &:hover {
+ background-color: #4745b5;
+ }
+}
+
+header .cache-btn {
+ margin-left: 18px;
+ height: 28px;
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ font-size: 13px;
+ color: #7b61ff;
+ background: none;
+ padding: 4px 8px;
+ border-radius: 6px;
+ border: 1px solid #7b61ff;
+ transition: color 0.3s linear;
+ &:hover {
+ cursor: pointer;
+ background-color: #5856d6;
+ color: #fff;
+ }
+}
+
+header .exit-btn {
+ background: none;
+ margin-right: 120px;
+ cursor: pointer;
+ border: none;
+ color: #333333;
+ text-decoration: none;
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ font-size: 13px;
+ &-cross {
+ margin-left: 7px;
+ }
+}
+
+header #rectangle {
+ padding: 0 12px;
+ margin: 22px 120px;
+ width: 534px;
+ height: 106px;
+ border-radius: 6px;
+ background: #f4638633;
+ border: 2px solid #f46386;
+}
+
+.source {
+ margin-top: 7px;
+ color: #f46386;
+ font-family: "Roboto", sans-serif;
+ font-weight: 700;
+ font-size: 13px;
+ &-first-word {
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ }
+}
+
+.date-event {
+ float: right;
+}
+
+.name-event {
+ margin-top: 26px;
+ &-text {
+ color: #2c2d2e;
+ font-family: "Unbounded", sans-serif;
+ font-weight: 700;
+ font-size: 15px;
+ }
+}
+
+.task-event {
+ margin-top: 7px;
+ &-text {
+ color: #2c2d2e;
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ font-size: 15px;
+ }
+}
+
+.input-name {
+ margin-top: 8px;
+ width: 320px;
+ height: 32px;
+ border-radius: 4px;
+ border-color: #d9d9d9;
+}
+
+.input-email {
+ margin-top: 8px;
+ width: 320px;
+ height: 32px;
+ border-radius: 4px;
+ border-color: #d9d9d9;
+}
+
+.login-btn-log {
+ width: 100px;
+ position: relative;
+ cursor: pointer;
+ border: none;
+ margin: 6px auto;
+ background-color: #5856d6;
+ color: #fff;
+ text-decoration: none;
+ border-radius: 6px;
+ padding: 10px 20px;
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ transition: color 0.2s linear;
+ &:hover {
+ background-color: #4745b5;
+ }
+}
+
+.login-container {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+}
+
+.shedule-login {
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 32px;
+}
+
+.login-netologiya,
+.login-fio {
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ font-size: 15px;
+ max-width: 320px;
+ width: 100%;
+ margin: 24px auto;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ flex-direction: column;
+ text-align: left;
+}
+
+.calendar {
+ position: absolute;
+ top: 165px;
+ right: 248px;
+ background-color: #ecedf0;
+ width: 406px;
+ height: 32px;
+ border-radius: 6px;
+ border: 1px solid rgba(217, 217, 217, 0.5);
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 15px;
+ padding-left: 14px;
+ transition: color 0.2s linear;
+ &:hover {
+ background-color: #e7e7ea;
+ }
+}
+
+.shedule-table {
+ width: 100%;
+ margin-top: 36px;
+ border-spacing: 1px;
+}
+
+.days {
+ border-bottom: 1px dotted #adadad;
+ &-1 {
+ color: #adadad;
+ padding-left: 30px;
+ padding-bottom: 10px;
+ padding-top: 10px;
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 13px;
+ border: 1px;
+ border-bottom: 1px dotted #adadad;
+ }
+ &-2 {
+ color: #7b61ff;
+ padding-left: 30px;
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 13px;
+ border: 1px;
+ border-bottom: 1px dotted #adadad;
+ }
+ &-3 {
+ color: #333333;
+ padding-left: 30px;
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 13px;
+ border: 1px;
+ border-bottom: 1px dotted #adadad;
+ }
+ &-4 {
+ color: #333333;
+ padding-left: 30px;
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 13px;
+ border: 1px;
+ border-bottom: 1px dotted #adadad;
+ }
+ &-5 {
+ color: #333333;
+ padding-left: 30px;
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 13px;
+ border: 1px;
+ border-bottom: 1px dotted #adadad;
+ }
+ &-6 {
+ color: #333333;
+ padding-left: 30px;
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 13px;
+ border: 1px;
+ border-bottom: 1px dotted #adadad;
+ }
+ &-7 {
+ color: #333333;
+ padding-left: 30px;
+ font-family: "Unbounded", sans-serif;
+ font-weight: 500;
+ font-size: 13px;
+ border: 1px;
+ border-bottom: 1px dotted #adadad;
+ }
+}
+
+.vertical-deadline {
+ vertical-align: top;
+ height: 72px;
+ width: 195px;
+ padding-left: 20px;
+ border-bottom: 1px dotted #adadad;
+ &-heading {
+ padding-left: 47px;
+ padding-top: 7px;
+ padding-bottom: 7px;
+ font-family: "Roboto Mono", monospace;
+ font-weight: 400;
+ font-size: 12px;
+ width: 0;
+ border-bottom: 1px dotted #adadad;
+ &::first-line {
+ font-family: "Roboto", sans-serif;
+ font-weight: 700;
+ font-size: 13px;
+ }
+ }
+}
+
+.vertical {
+ height: 72px;
+ width: 195px;
+ padding-left: 20px;
+ border-bottom: 1px dotted #adadad;
+ &-heading {
+ padding-left: 47px;
+ padding-top: 7px;
+ padding-bottom: 7px;
+ font-family: "Roboto Mono", monospace;
+ font-weight: 400;
+ font-size: 12px;
+ width: 0;
+ border-bottom: 1px dotted #adadad;
+ &::first-line {
+ font-family: "Roboto", sans-serif;
+ font-weight: 700;
+ font-size: 13px;
+ }
+ }
+}
+
+.off-deadline {
+ margin-top: 7px;
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ font-size: 13px;
+ color: #7b61ff;
+ background: none;
+ padding: 4px 8px;
+ border-radius: 6px;
+ border: 1px solid #7b61ff;
+ transition: color 0.3s linear;
+ &:hover {
+ cursor: pointer;
+ background-color: #5856d6;
+ color: #fff;
+ }
+}
+
+.deadline {
+ &-info {
+ height: 28px;
+ margin-top: 6px;
+ width: 95%;
+ text-align: left;
+ padding: 5px 11px;
+ border: none;
+ background: #F46386;
+ border-radius: 6px;
+ color: #fff;
+ font-family: "Roboto", sans-serif;
+ font-weight: 700;
+ font-size: 13px;
+ transition: color 0.3s linear;
+ &:hover {
+ cursor: pointer;
+ color: #F46386;
+ background-color: #fff;
+ border: 2px solid #F46386;
+ }
+ }
+ &-info-on {
+ height: 28px;
+ margin-top: 4px;
+ width: 95%;
+ text-align: left;
+ padding: 5px 11px;
+ color: #F46386;
+ background-color: #fff;
+ border: 2px solid #F46386;
+ border-radius: 6px;
+ font-family: "Roboto", sans-serif;
+ font-weight: 700;
+ font-size: 13px;
+ transition: color 0.3s linear;
+ &:hover {
+ cursor: pointer;
+ color: #fff;
+ background-color: #F46386;
+ border: none;
+ }
+ }
+}
+
+.past-lesson {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ padding-left: 12px;
+ text-align: left;
+ width: 95%;
+ height: 95%;
+ background: #ECEDF0;
+ border: none;
+ border-radius: 6px;
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.company-name {
+ vertical-align: top;
+ font-family: "Roboto", sans-serif;
+ font-weight: 500;
+ font-size: 10px;
+ color: #7B61FF;
+}
+
+.lesson-name {
+ vertical-align: top;
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ font-size: 13px;
+ color: #2C2D2E;
+}
+
+.netology-lesson {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ padding-left: 12px;
+ text-align: left;
+ width: 95%;
+ height: 95%;
+ background: #00A8A833;
+ border: none;
+ border-radius: 6px;
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.TyumGU-lesson {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ padding-left: 12px;
+ text-align: left;
+ width: 95%;
+ height: 95%;
+ background: #7B61FF33;
+ border: none;
+ border-radius: 6px;
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.vertical-line {
+ margin-top: 17px;
+ margin-left: 365px;
+ position: absolute;
+ border-left: 2px solid #7B61FF;
+ height: 590px;
+}
+
+.error-message {
+ font-family: "Roboto", sans-serif;
+ font-weight: 400;
+ font-size: 15px;
+ text-align: center;
+ color: red;
+ margin-top: 10px;
+}
diff --git a/frontend/src/pages/CalendarRoute.jsx b/frontend/src/pages/CalendarRoute.jsx
index 1e2fa53..aca706a 100644
--- a/frontend/src/pages/CalendarRoute.jsx
+++ b/frontend/src/pages/CalendarRoute.jsx
@@ -1,13 +1,118 @@
-import DataPicker from "../components/Calendar/DataPicker"
-import Header from "../components/Header/Header";
+import React, {useContext, useEffect, useState} from "react";
+import { getNetologyCourse, bulkEvents } from "../services/api";
+import {AuthContext} from "../context/AuthContext"; // Импортируем API функции
const CalendarRoute = () => {
+ const { authData } = useContext(AuthContext); // Достаем данные из контекста
+
+ const [calendarId, setCalendarId] = useState(null); // Хранение calendarId
+ const [courses, setCourses] = useState({ homework: [], webinars: [] }); // Для хранения курсов
+ const [events, setEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ console.log("Данные пользователя:", authData);
+
+ const fetchCourseAndEvents = async () => {
+ const sessionToken = localStorage.getItem("token"); // Получаем токен из localStorage
+
+ try {
+ // Получаем данные курса, чтобы извлечь calendarId
+ const courseData = await getNetologyCourse(sessionToken);
+ const fetchedCalendarId = courseData?.id; // Предполагаем, что calendarId есть в данных курса
+ setCalendarId(fetchedCalendarId);
+
+ // Сохраняем курсы (домашние задания и вебинары)
+ if (courseData?.netology) {
+ setCourses(courseData.netology);
+ }
+
+
+ if (fetchedCalendarId) {
+ // дату получаем события для календаря
+ const eventsResponse = await bulkEvents(
+ authData.email, // username
+ authData.password, // password
+ sessionToken, // Токен сессии
+ fetchedCalendarId, // Извлеченный ID календаря
+ "2024-10-07T00:00:00+03:00", // Начало диапазона дат
+ "2024-10-13T23:59:59+03:00", // Конец диапазона дат
+ authData.personId // ID участника
+ );
+
+ setEvents(eventsResponse.data); // Записываем события в состояние
+ }
+ } catch (error) {
+ setError("Ошибка получения данных с сервера");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchCourseAndEvents();
+ }, [authData]);
+
+ if (loading) {
+ return Загрузка...
;
+ }
+
+ if (error) {
+ return {error}
;
+ }
+
return (
-
-
+
Календарь
+ {calendarId ?
ID Календаря: {calendarId}
:
Календарь не найден
}
+
+
События:
+ {events.length > 0 ? (
+
+ {events.map((event, index) => (
+ -
+ {event.title} — {event.date}
+
+ ))}
+
+ ) : (
+
Нет доступных событий
+ )}
+
+
Курсы
+
Домашние задания
+ {courses.homework.length > 0 ? (
+
+ ) : (
+
Нет домашних заданий
+ )}
+
+
Вебинары
+ {courses.webinars.length > 0 ? (
+
+ ) : (
+
Нет вебинаров
+ )}
- )
-}
+ );
+};
+
+export default CalendarRoute;
+
-export default CalendarRoute
diff --git a/frontend/src/pages/LoginRoute.jsx b/frontend/src/pages/LoginRoute.jsx
index 6b0f062..b07cd50 100644
--- a/frontend/src/pages/LoginRoute.jsx
+++ b/frontend/src/pages/LoginRoute.jsx
@@ -1,11 +1,147 @@
-import ModeusLoginForm from "../components/Login/ModeusLoginForm"
+import {useContext, useState} from "react";
+import {useNavigate} from "react-router-dom";
+import {AuthContext} from "../context/AuthContext";
+import {loginModeus, searchModeus} from "../services/api";
const LoginRoute = () => {
+ const {setAuthData} = useContext(AuthContext); // Достаем setAuthData из контекста
+
+ const [email, setEmail] = useState(null);
+ const [password, setPassword] = useState(null);
+ const [fullName, setFullName] = useState(""); // Строка для поиска
+ const [searchResults, setSearchResults] = useState([]); // Результаты поиска
+ // const [selectedName, setSelectedName] = useState(""); // Выбранное имя
+ const [personId, setPersonId] = useState(null); // ID выбранного человека
+ const [showSuggestions, setShowSuggestions] = useState(false); // Флаг показа списка
+ const [errorMessage, setErrorMessage] = useState(""); // Сообщение об ошибке
+
+ const navigate = useNavigate(); // Инициализируем хук для навигации
+
+ const onClickSearch = async (fullName) => {
+ console.log("Поиск ФИО:", fullName);
+
+ let response = await searchModeus(fullName);
+ if (response.status !== 200) {
+ setErrorMessage("Неверное ФИО. Попробуйте еще раз.");
+ return;
+ }
+ console.log("Результаты поиска:", response.data);
+ setSearchResults(response.data);
+ setShowSuggestions(true); // Показываем список после поиска
+ setErrorMessage(""); // Очищаем ошибку при успешном поиске
+ };
+
+ /// Обработчик нажатия клавиши "Enter"
+ const handleKeyPress = (e) => {
+ if (e.key === "Enter") {
+ onClickSearch(fullName); // Выполнить поиск, если нажата клавиша Enter
+ }
+ };
+
+ // Обработчик выбора варианта из списка
+ const handleSelect = (person) => {
+ console.log('person', person)
+ setFullName(person.fullName); // Устанавливаем выбранное имя
+ setPersonId(person.personId);
+
+ // setAuthData((prev) => ({
+ // person: person,
+ // personId: personId, // Сохраняем personId в контекст
+ // ...prev,
+ // }));
+
+ // setAuthData({ person });
+ setShowSuggestions(false); // Скрываем список после выбора
+ };
+
+ const onClickLogin = async () => {
+ let response = await loginModeus(email, password);
+ // console.log('email', email)
+ // console.log('password', password)
+
+ if (response.status !== 200) {
+ setErrorMessage("Неверный логин или пароль. Попробуйте еще раз."); // Устанавливаем текст ошибки
+ return;
+ }
+
+ console.log('setAuthData получил', setAuthData)
+
+ // Set email, password, and personId in the AuthContext
+ setAuthData({ email, password });
+
+ console.log('setAuthData передал ', setAuthData)
+
+ localStorage.setItem("token", response.data["_netology-on-rails_session"]);
+ setErrorMessage(""); // Очищаем ошибку при успешном логине
+
+ // Перенаправление на страницу календаря
+ navigate("/calendar");
+ window.location.reload(); // Обновляем страницу после навигации
+ };
+
return (
-
-
+
+
Мое расписание
+
+
+
+
+
+
setFullName(e.target.value)} // Обновляем строку поиска
+ onKeyPress={handleKeyPress} // Обработчик для нажатия клавиш
+ />
+
+ {/* Рендерим выпадающий список или сообщение об отсутствии результатов */}
+ {showSuggestions && (
+
+ {searchResults.length > 0 ? (
+ searchResults.map((person, index) => (
+ - handleSelect(person)}>
+ {person.fullName} {/* Отображаем имя */}
+
+ ))
+ ) : (
+ - Нет такого имени
// Сообщение, если список пуст
+ )}
+
+ )}
+
+
+
+
+
+
+
+ setEmail(e.target.value)}
+ />
+ setPassword(e.target.value)}
+ />
+
+ {/* Сообщение об ошибке */}
+ {errorMessage &&
{errorMessage}
}
+
+
+ Войти
+
+
- )
+ );
}
export default LoginRoute
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
new file mode 100644
index 0000000..388f054
--- /dev/null
+++ b/frontend/src/services/api.js
@@ -0,0 +1,94 @@
+import axios from 'axios';
+
+// env variable
+const BACKEND_URL = process.env.REACT_APP_BACKEND_URL;
+
+
+console.log("Backend URL:", BACKEND_URL);
+
+export function getTokenFromLocalStorage() {
+ return localStorage.getItem('token')
+}
+
+// login
+export async function loginModeus(username, password) {
+ try {
+ return await axios.post(`${BACKEND_URL}/api/netology/auth`, {username, password});
+ } catch (e) {
+ return e.response;
+ }
+}
+
+export async function searchModeus(fullName) {
+ try {
+ const params = new URLSearchParams();
+ params.append('full_name', fullName);
+ return await axios.get(`${BACKEND_URL}/api/modeus/search/?full_name=${fullName}`);
+
+ } catch (e) {
+ return e.response;
+ }
+}
+// calendar_id
+export async function getNetologyCourse(sessionToken) {
+ console.log('sessionToken', sessionToken)
+ try {
+ const response = await axios.get(`${BACKEND_URL}/api/netology/course/`, {
+ headers: {
+ "_netology-on-rails_session": sessionToken, // Токен сессии передается в заголовке
+ "Content-Type": "application/json"
+ }
+ });
+ return response.data; // Возвращаем данные
+ } catch (e) {
+ return e.response;
+ }
+}
+// calendar
+export async function bulkEvents(username, password, sessionToken, calendarId, timeMin, timeMax, attendeePersonId) {
+ try {
+ const response = await axios.post(
+ `${BACKEND_URL}/api/bulk/events/?calendar_id=${calendarId}`,
+ {
+ timeMin,
+ timeMax,
+ size: 50,
+ attendeePersonId: [attendeePersonId],
+ },
+ {
+ headers: {
+ "_netology-on-rails_session": sessionToken, // Токен сессии
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ return response;
+ } catch (e) {
+ return e.response;
+ }
+}
+// Refresh calendar
+export async function refreshBulkEvents(sessionToken, calendarId, timeMin, timeMax, attendeePersonId) {
+ try {
+ const response = await axios.post(
+ `${BACKEND_URL}/api/bulk/refresh_events/?calendar_id=${calendarId}`,
+ {
+ timeMin,
+ timeMax,
+ size: 50,
+ attendeePersonId: [attendeePersonId],
+ },
+ {
+ headers: {
+ "_netology-on-rails_session": sessionToken, // Токен сессии
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ return response;
+ } catch (e) {
+ return e.response;
+ }
+}
+
+
diff --git a/frontend/src/services/api/login.js b/frontend/src/services/api/login.js
deleted file mode 100644
index b678ab8..0000000
--- a/frontend/src/services/api/login.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import axios from 'axios';
-import { BACKEND_URL } from '../../variables';
-
-
-export function getTokenFromLocalStorage() {
- return localStorage.getItem('token')
-}
-
-export async function loginModeus(username, password) {
- try {
- return await axios.post(`${BACKEND_URL}/api/netology/auth`, {username, password});
- } catch (e) {
- return e.response;
- }
-}
-
-export async function searchModeus(fullName) {
- try {
- const params = new URLSearchParams();
- params.append('full_name', fullName);
- return await axios.get(`${BACKEND_URL}/api/modeus/search/?full_name=${fullName}`);
-
- } catch (e) {
- return e.response;
- }
-}
\ No newline at end of file
diff --git a/frontend/src/variables.js b/frontend/src/variables.js
deleted file mode 100644
index 5b61d0e..0000000
--- a/frontend/src/variables.js
+++ /dev/null
@@ -1 +0,0 @@
-export const BACKEND_URL="http://localhost:8000"
\ No newline at end of file