diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml index 3902439..a0ea03c 100644 --- a/backend/.pre-commit-config.yaml +++ b/backend/.pre-commit-config.yaml @@ -47,12 +47,6 @@ repos: entry: poetry run python ./scripts/generate_settings_schema.py pass_filenames: false files: ^src/config_schema.py$ - - id: poetry-check - name: poetry check - entry: poetry check - language: system - pass_filenames: false - files: "pyproject.toml" - id: alembic-auto-migrations name: generate alembic auto migrations files language: system diff --git a/backend/poetry.lock b/backend/poetry.lock index 143a9ec..4a3b839 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -465,6 +465,24 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "icalendar" +version = "6.1.0" +description = "iCalendar parser/generator" +optional = false +python-versions = ">=3.8" +files = [ + {file = "icalendar-6.1.0-py3-none-any.whl", hash = "sha256:46c09b774a6e6948495dafcb166dc15135c8259d0ae25491f154cbc822714b69"}, + {file = "icalendar-6.1.0.tar.gz", hash = "sha256:43c2db8632959d634f4e48f6e6131e706bf2cdddad488cf0b72fda079b796bad"}, +] + +[package.dependencies] +python-dateutil = "*" +tzdata = "*" + +[package.extras] +test = ["coverage", "hypothesis", "pytest", "pytz"] + [[package]] name = "identify" version = "2.6.2" @@ -839,6 +857,20 @@ snappy = ["python-snappy"] test = ["pytest (>=8.2)", "pytest-asyncio (>=0.24.0)"] zstd = ["zstandard"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pyyaml" version = "6.0.2" @@ -928,6 +960,17 @@ files = [ {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, ] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -978,6 +1021,17 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "tzdata" +version = "2024.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, + {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, +] + [[package]] name = "uvicorn" version = "0.31.1" @@ -1019,4 +1073,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "3dd5bbcabe89bae1fca0dddf93b39a3c93755df8d7aeaf22c952668fded62e25" +content-hash = "73ac8f3cb3b00aa41c313631a1b021d91de5c8aaa91e09975c42dad40ecf2d0b" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b8922f3..8c9b478 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -16,6 +16,7 @@ fastapi = "^0.115.0" fastapi-swagger = "^0.2.3" gunicorn = "^23.0.0" httpx = "^0.27.2" +icalendar = "^6.1.0" itsdangerous = "^2.2.0" passlib = "^1.7.4" pre-commit = "^3.8.0" diff --git a/backend/src/modules/events/repository.py b/backend/src/modules/events/repository.py index 1bef999..f2817b4 100644 --- a/backend/src/modules/events/repository.py +++ b/backend/src/modules/events/repository.py @@ -6,6 +6,7 @@ from src.modules.events.schemas import Filters, Pagination, Sort from src.storages.mongo.events import Event +from src.storages.mongo.selection import Selection # noinspection PyMethodMayBeStatic @@ -114,5 +115,13 @@ async def read_with_filters( # Return results return await query.to_list() + async def create_selection(self, filters: Filters, sort: Sort): + selection = Selection(filters=filters, sort=sort) + await selection.insert() + return selection + + async def read_selection(self, id_: PydanticObjectId) -> Selection | None: + return await Selection.get(id_) + events_repository: EventsRepository = EventsRepository() diff --git a/backend/src/modules/events/routes.py b/backend/src/modules/events/routes.py index 6444ea9..659735b 100644 --- a/backend/src/modules/events/routes.py +++ b/backend/src/modules/events/routes.py @@ -1,14 +1,18 @@ -from datetime import datetime +from datetime import datetime, timedelta +import icalendar from beanie import PydanticObjectId from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from starlette.responses import Response from src.api.exceptions import IncorrectCredentialsException from src.modules.events.repository import events_repository from src.modules.events.schemas import DateFilter, Filters, Pagination, Sort +from src.modules.ics_utils import get_base_calendar from src.modules.sports.repository import sports_repository from src.storages.mongo.events import Event +from src.storages.mongo.selection import Selection router = APIRouter( prefix="/events", @@ -188,3 +192,75 @@ async def get_all_filters_disciplines() -> list[DisciplinesFilterVariants]: ) for sport in sports ] + + +@router.post("/search/share", responses={200: {"description": "Share selection"}}) +async def share_selection(filters: Filters, sort: Sort) -> Selection: + """ + Share selection. Use this for .ics too. + """ + selection = await events_repository.create_selection(filters, sort) + return selection + + +@router.get( + "/seach/share/{selection_id}", + responses={200: {"description": "Get selection"}, 404: {"description": "Selection not found"}}, +) +async def get_selection(selection_id: PydanticObjectId) -> Selection: + """ + Get selection. + """ + selection = await events_repository.read_selection(selection_id) + if selection is None: + raise HTTPException(status_code=404, detail="Selection not found") + + return selection + + +@router.get( + "/search/share/{selection_id}.ics", + response_class=Response, + responses={ + 200: {"description": "Get selection in .ics format"}, + 404: {"description": "Selection not found"}, + }, +) +async def get_selection_ics(selection_id: PydanticObjectId): + selection = await events_repository.read_selection(selection_id) + if selection is None: + raise HTTPException(status_code=404, detail="Selection not found") + + date_filter = DateFilter() + # week before and month after + date_filter.start_date = datetime.now() - timedelta(days=7) + date_filter.end_date = datetime.now() + timedelta(days=30) + selection.filters.date = date_filter + + events = await events_repository.read_with_filters( + filters=selection.filters, + sort=selection.sort, + pagination=Pagination(page_size=1000, page_no=1), + ) + calendar = get_base_calendar() + calendar["x-wr-calname"] = "Подборка Спортивных Событий" + + for event in events: + uid = f"{str(event.id)}@innohassle.ru" + + vevent = icalendar.Event() + vevent.add("uid", uid) + + vevent.add("summary", f"{event.sport}: {event.title}") + vevent.add("dtstart", icalendar.vDate(event.start_date)) + vevent.add("dtend", icalendar.vDate(event.end_date)) + vevent.add("description", event.description) + if event.location: + vevent.add("location", "\n".join([str(loc) for loc in event.location])) + calendar.add_component(vevent) + + return Response( + content=calendar.to_ical(), + media_type="text/calendar", + headers={"Content-Disposition": 'attachment; filename="schedule.ics"'}, + ) diff --git a/backend/src/modules/ics_utils.py b/backend/src/modules/ics_utils.py new file mode 100644 index 0000000..fbe4ee1 --- /dev/null +++ b/backend/src/modules/ics_utils.py @@ -0,0 +1,36 @@ +import datetime + +import icalendar + +TIMEZONE = "Europe/Moscow" + + +def get_base_calendar() -> icalendar.Calendar: + """ + Get base calendar with default properties (version, prodid, etc.) + :return: base calendar + :rtype: icalendar.Calendar + """ + + calendar = icalendar.Calendar( + prodid="-//one-zero-eight//Календарь Спорта РФ", + version="2.0", + method="PUBLISH", + ) + + calendar["x-wr-caldesc"] = "Календарь Спорта РФ" + calendar["x-wr-timezone"] = TIMEZONE + # + # add timezone + timezone = icalendar.Timezone(tzid=TIMEZONE) + timezone["x-lic-location"] = TIMEZONE + # add standard timezone + standard = icalendar.TimezoneStandard() + standard.add("tzoffsetfrom", datetime.timedelta(hours=3)) + standard.add("tzoffsetto", datetime.timedelta(hours=3)) + standard.add("tzname", "MSK") + standard.add("dtstart", datetime.datetime(1970, 1, 1)) + timezone.add_component(standard) + calendar.add_component(timezone) + + return calendar diff --git a/backend/src/modules/sports/repository.py b/backend/src/modules/sports/repository.py index f0e606f..98fb5df 100644 --- a/backend/src/modules/sports/repository.py +++ b/backend/src/modules/sports/repository.py @@ -1,11 +1,13 @@ __all__ = ["sports_repository"] +from beanie import PydanticObjectId + from src.storages.mongo.sports import Sport # noinspection PyMethodMayBeStatic class SportsRepository: - async def read_one(self, id: str) -> Sport | None: + async def read_one(self, id: PydanticObjectId) -> Sport | None: return await Sport.get(id) async def read_all(self) -> list[Sport] | None: diff --git a/backend/src/modules/sports/routes.py b/backend/src/modules/sports/routes.py index 2951647..cbd2093 100644 --- a/backend/src/modules/sports/routes.py +++ b/backend/src/modules/sports/routes.py @@ -1,6 +1,12 @@ -from fastapi import APIRouter +import icalendar +from beanie import PydanticObjectId +from fastapi import APIRouter, HTTPException +from starlette.responses import Response from src.api.exceptions import IncorrectCredentialsException +from src.modules.events.repository import events_repository +from src.modules.events.schemas import DisciplineFilter, Filters, Order, Pagination, Sort +from src.modules.ics_utils import get_base_calendar from src.modules.sports.repository import sports_repository from src.storages.mongo import Sport @@ -22,7 +28,7 @@ async def get_all_sports() -> list[Sport]: @router.get("/{id}", responses={200: {"description": "Info about sport"}}) -async def get_sport(id: str) -> Sport: +async def get_sport(id: PydanticObjectId) -> Sport: """ Get info about one sport. """ @@ -43,3 +49,48 @@ async def update_descriptions(name_x_descriptions: dict[str, str]) -> None: Update descriptions of sports. """ await sports_repository.update_decriptions(name_x_descriptions) + + +@router.get( + "/{id}/.ics", + response_class=Response, + responses={ + 200: {"description": "Get sport's events in .ics format"}, + 404: {"description": "Sport not found"}, + }, +) +async def get_sport_ics(id: PydanticObjectId) -> Response: + sport = await sports_repository.read_one(id) + if sport is None: + raise HTTPException(status_code=404, detail="Sport not found") + + filters = Filters(discipline=[DisciplineFilter(sport=sport.sport)]) + + events = await events_repository.read_with_filters( + filters=filters, + sort=Sort(date=Order.asc), + pagination=Pagination(page_size=1000, page_no=1), + ) + + calendar = get_base_calendar() + calendar["x-wr-calname"] = f"Подборка Спортивных Событий ({sport.sport})" + + for event in events: + uid = f"{str(event.id)}@innohassle.ru" + + vevent = icalendar.Event() + vevent.add("uid", uid) + + vevent.add("summary", f"{event.title}") + vevent.add("dtstart", icalendar.vDate(event.start_date)) + vevent.add("dtend", icalendar.vDate(event.end_date)) + vevent.add("description", f"{event.sport}\n\n{event.title}") + if event.location: + vevent.add("location", "\n".join([str(loc) for loc in event.location])) + calendar.add_component(vevent) + + return Response( + content=calendar.to_ical(), + media_type="text/calendar", + headers={"Content-Disposition": 'attachment; filename="sport.ics"'}, + ) diff --git a/backend/src/storages/mongo/__init__.py b/backend/src/storages/mongo/__init__.py index 7eec4b1..dfd9a7c 100644 --- a/backend/src/storages/mongo/__init__.py +++ b/backend/src/storages/mongo/__init__.py @@ -4,10 +4,11 @@ from src.storages.mongo.events import Event from src.storages.mongo.notifies import Notification +from src.storages.mongo.selection import Selection from src.storages.mongo.sports import Sport from src.storages.mongo.users import User document_models = cast( list[type[Document] | type[View] | str], - [User, Event, Sport, Notification], + [User, Event, Sport, Notification, Selection], ) diff --git a/backend/src/storages/mongo/events.py b/backend/src/storages/mongo/events.py index cade906..dea1f56 100644 --- a/backend/src/storages/mongo/events.py +++ b/backend/src/storages/mongo/events.py @@ -18,6 +18,14 @@ class EventLocation(BaseSchema): city: str | None = None "Название города" + def __str__(self): + s = self.country + if self.region: + s += f", {self.region}" + if self.city: + s += f", {self.city}" + return s + class EventSchema(BaseSchema): ekp_id: int diff --git a/backend/src/storages/mongo/selection.py b/backend/src/storages/mongo/selection.py new file mode 100644 index 0000000..5cde021 --- /dev/null +++ b/backend/src/storages/mongo/selection.py @@ -0,0 +1,14 @@ +from src.modules.events.schemas import Filters, Sort +from src.pydantic_base import BaseSchema +from src.storages.mongo.__base__ import CustomDocument + + +class SelectionSchema(BaseSchema): + filters: Filters + "Filter for the selection." + sort: Sort = Sort() + "Sort for the selection." + + +class Selection(SelectionSchema, CustomDocument): + pass diff --git a/parser/sports_description.py b/parser/sports_description.py index 7c845c4..a4e8a72 100644 --- a/parser/sports_description.py +++ b/parser/sports_description.py @@ -34,3 +34,27 @@ import json json.dump(sports_desc, f, ensure_ascii=False, indent=2) + +with open("sports.json") as f: + sports = json.load(f) + +# add description to sports.json +for sport_obj in sports: + sport = sport_obj["sport"] + if sport in sports_desc: + sport_obj["description"] = sports_desc[sport] + +with open("sports.json", "w") as f: + json.dump(sports, f, ensure_ascii=False, indent=2) + +with open("sports.json") as f: + sports = json.load(f) + +# add description to sports.json +for sport_obj in sports: + sport = sport_obj["sport"] + if sport in sports_desc: + sport_obj["description"] = sports_desc[sport] + +with open("sports.json", "w") as f: + json.dump(sports, f, ensure_ascii=False, indent=2)